Markdown.vue 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. <script lang="ts" setup>
  2. import type { Options as MarkdownOptions } from 'markdown-it'
  3. import Markdown from 'markdown-it'
  4. import { full as emoji } from 'markdown-it-emoji'
  5. import markdownLink from 'markdown-it-link-attributes'
  6. import markdownTaskLists from 'markdown-it-task-lists'
  7. import { computed, ref } from 'vue'
  8. import xss, { whiteList } from 'xss'
  9. import { toggleCheckbox, serializeAttr } from './utils'
  10. interface IImage {
  11. id: string | number
  12. url: string
  13. }
  14. interface Options {
  15. markdown: MarkdownOptions
  16. linkAttributes: markdownLink.Config
  17. tasklists: markdownTaskLists.Config
  18. }
  19. interface MarkdownProps {
  20. content?: string | null
  21. withMultiBreaks?: boolean
  22. images?: IImage[]
  23. loading?: boolean
  24. loadingBlocks?: number
  25. loadingRows?: number
  26. theme?: string
  27. options?: Options
  28. }
  29. const props = withDefaults(defineProps<MarkdownProps>(), {
  30. content: '',
  31. withMultiBreaks: false,
  32. images: () => [],
  33. loading: false,
  34. loadingBlocks: 2,
  35. loadingRows: 3,
  36. theme: 'markdown',
  37. options: () => ({
  38. markdown: {
  39. html: false,
  40. linkify: true,
  41. typographer: true,
  42. breaks: true
  43. },
  44. linkAttributes: {
  45. attrs: {
  46. target: '_blank',
  47. rel: 'noopener'
  48. }
  49. },
  50. tasklists: {
  51. enabled: true,
  52. label: true,
  53. labelAfter: false
  54. }
  55. })
  56. })
  57. const editor = ref<HTMLDivElement | undefined>(undefined)
  58. const { options } = props
  59. const md = new Markdown(options.markdown)
  60. .use(markdownLink, options.linkAttributes)
  61. .use(emoji)
  62. .use(markdownTaskLists, options.tasklists)
  63. const xssWhiteList = {
  64. ...whiteList,
  65. label: ['class', 'for'],
  66. iframe: ['width', 'height', 'src', 'title', 'frameborder', 'allow', 'referrerpolicy']
  67. }
  68. const htmlContent = computed(() => {
  69. if (!props.content) {
  70. return ''
  71. }
  72. const imageUrls: { [key: string]: string } = {}
  73. if (props.images) {
  74. props.images.forEach((image: IImage) => {
  75. if (!image) {
  76. // Happens if an image got deleted but the workflow
  77. // still has a reference to it
  78. return
  79. }
  80. imageUrls[image.id] = image.url
  81. })
  82. }
  83. const fileIdRegex = new RegExp('fileId:([0-9]+)')
  84. let contentToRender = props.content
  85. if (props.withMultiBreaks) {
  86. contentToRender = contentToRender.replaceAll('\n\n', '\n&nbsp;\n')
  87. }
  88. const html = md.render(contentToRender)
  89. const safeHtml = xss(html, {
  90. onTagAttr(tag, name, value) {
  91. if (tag === 'img' && name === 'src') {
  92. if (value.match(fileIdRegex)) {
  93. const id = value.split('fileId:')[1]
  94. const imageUrl = imageUrls[id]
  95. if (!imageUrl) {
  96. return ''
  97. }
  98. return serializeAttr(tag, name, imageUrl)
  99. }
  100. // Only allow http requests to supported image files from the `static` directory
  101. const isImageFile = value.split('#')[0].match(/\.(jpeg|jpg|gif|png|webp)$/) !== null
  102. const isStaticImageFile = isImageFile && value.startsWith('/static/')
  103. if (!value.startsWith('https://') && !isStaticImageFile) {
  104. return ''
  105. }
  106. }
  107. // Return nothing, means keep the default handling measure
  108. return
  109. },
  110. onTag(tag, code) {
  111. if (tag === 'img' && code.includes('alt="workflow-screenshot"')) {
  112. return ''
  113. }
  114. // return nothing, keep tag
  115. return
  116. },
  117. onIgnoreTag(tag, tagHTML) {
  118. // Allow checkboxes
  119. if (tag === 'input' && tagHTML.includes('type="checkbox"')) {
  120. return tagHTML
  121. }
  122. return
  123. },
  124. whiteList: xssWhiteList
  125. })
  126. return safeHtml
  127. })
  128. const emit = defineEmits<{
  129. 'markdown-click': [link: HTMLAnchorElement, e: MouseEvent]
  130. 'update-content': [content: string]
  131. }>()
  132. const onClick = (event: MouseEvent) => {
  133. let clickedLink: HTMLAnchorElement | null = null
  134. if (event.target instanceof HTMLAnchorElement) {
  135. clickedLink = event.target
  136. }
  137. if (event.target instanceof HTMLElement && event.target.matches('a *')) {
  138. const parentLink = event.target.closest('a')
  139. if (parentLink) {
  140. clickedLink = parentLink
  141. }
  142. }
  143. if (clickedLink) {
  144. emit('markdown-click', clickedLink, event)
  145. }
  146. }
  147. // Handle checkbox changes
  148. const onChange = async (event: Event) => {
  149. if (event.target instanceof HTMLInputElement && event.target.type === 'checkbox') {
  150. const checkboxes = editor.value?.querySelectorAll('input[type="checkbox"]')
  151. if (checkboxes) {
  152. // Get the index of the checkbox that was clicked
  153. const index = Array.from(checkboxes).indexOf(event.target)
  154. if (index !== -1) {
  155. onCheckboxChange(index)
  156. }
  157. }
  158. }
  159. }
  160. const onMouseDown = (event: MouseEvent) => {
  161. // Mouse down on input fields is caught by node view handlers
  162. // which prevents checking them, this will prevent that
  163. if (event.target instanceof HTMLInputElement) {
  164. event.stopPropagation()
  165. }
  166. }
  167. // Update markdown when checkbox state changes
  168. const onCheckboxChange = (index: number) => {
  169. const currentContent = props.content
  170. if (!currentContent) {
  171. return
  172. }
  173. // We are using index to connect the checkbox with the corresponding line in the markdown
  174. const newContent = toggleCheckbox(currentContent, index)
  175. emit('update-content', newContent)
  176. }
  177. </script>
  178. <template>
  179. <div class="markdown">
  180. <!-- eslint-disable vue/no-v-html -->
  181. <div
  182. v-if="!loading"
  183. ref="editor"
  184. :class="$style[theme]"
  185. @click="onClick"
  186. @mousedown="onMouseDown"
  187. @change="onChange"
  188. v-html="htmlContent"
  189. />
  190. <!-- eslint-enable vue/no-v-html -->
  191. <div v-else :class="$style.markdown">
  192. <div v-for="(_, index) in loadingBlocks" :key="index" v-loading="loading">
  193. <div :class="$style.spacer" />
  194. </div>
  195. </div>
  196. </div>
  197. </template>
  198. <style lang="less" module>
  199. .markdown {
  200. color: var(--color--text);
  201. * {
  202. font-size: var(--font-size--md);
  203. line-height: var(--line-height--xl);
  204. }
  205. h1,
  206. h2,
  207. h3,
  208. h4 {
  209. margin-bottom: var(--spacing--sm);
  210. font-size: var(--font-size--md);
  211. font-weight: var(--font-weight--bold);
  212. }
  213. h3,
  214. h4 {
  215. font-weight: var(--font-weight--bold);
  216. }
  217. p,
  218. span {
  219. margin-bottom: var(--spacing--sm);
  220. }
  221. ul,
  222. ol {
  223. margin-bottom: var(--spacing--sm);
  224. padding-left: var(--spacing--md);
  225. li {
  226. margin-top: 0.25em;
  227. }
  228. }
  229. pre > code {
  230. background-color: var(--color--background);
  231. color: var(--color--text--shade-1);
  232. }
  233. li > code,
  234. p > code {
  235. padding: 0 var(--spacing--4xs);
  236. color: var(--color--text--shade-1);
  237. background-color: var(--color--background);
  238. }
  239. .label {
  240. color: var(--color--text);
  241. }
  242. img {
  243. max-width: 100%;
  244. border-radius: var(--radius--lg);
  245. }
  246. blockquote {
  247. padding-left: 10px;
  248. font-style: italic;
  249. border-left: var(--border-color) 2px solid;
  250. }
  251. }
  252. input[type='checkbox'] {
  253. accent-color: var(--color--primary);
  254. }
  255. input[type='checkbox'] + label {
  256. cursor: pointer;
  257. }
  258. .sticky {
  259. color: var(--sticky--color--text);
  260. overflow-wrap: break-word;
  261. h1,
  262. h2,
  263. h3,
  264. h4,
  265. h5,
  266. h6 {
  267. color: var(--sticky--color--text);
  268. }
  269. h1,
  270. h2,
  271. h3,
  272. h4 {
  273. margin-bottom: var(--spacing--2xs);
  274. font-weight: var(--font-weight--bold);
  275. line-height: var(--line-height--lg);
  276. }
  277. h1 {
  278. font-size: 36px;
  279. }
  280. h2 {
  281. font-size: 24px;
  282. }
  283. h3,
  284. h4,
  285. h5,
  286. h6 {
  287. font-size: var(--font-size--md);
  288. }
  289. p {
  290. margin-bottom: var(--spacing--2xs);
  291. font-size: var(--font-size--sm);
  292. font-weight: var(--font-weight--regular);
  293. line-height: var(--line-height--lg);
  294. }
  295. ul,
  296. ol {
  297. margin-bottom: var(--spacing--2xs);
  298. padding-left: var(--spacing--md);
  299. li {
  300. margin-top: 0.25em;
  301. font-size: var(--font-size--sm);
  302. font-weight: var(--font-weight--regular);
  303. line-height: var(--line-height--md);
  304. }
  305. &:has(input[type='checkbox']) {
  306. list-style-type: none;
  307. padding-left: var(--spacing--5xs);
  308. }
  309. }
  310. pre > code {
  311. background-color: var(--sticky--code--color--background);
  312. color: var(--sticky--code--color--text);
  313. }
  314. pre > code,
  315. li > code,
  316. p > code {
  317. color: var(--sticky--code--color--text);
  318. }
  319. a {
  320. &:hover {
  321. text-decoration: underline;
  322. }
  323. }
  324. img {
  325. object-fit: contain;
  326. margin-top: var(--spacing--xs);
  327. margin-bottom: var(--spacing--2xs);
  328. &[src*='#full-width'] {
  329. width: 100%;
  330. }
  331. }
  332. }
  333. .sticky,
  334. .markdown {
  335. pre {
  336. margin-bottom: var(--spacing--sm);
  337. display: grid;
  338. }
  339. pre > code {
  340. display: block;
  341. padding: var(--spacing--sm);
  342. overflow-x: auto;
  343. }
  344. iframe {
  345. aspect-ratio: 16/9 auto;
  346. }
  347. summary {
  348. cursor: pointer;
  349. }
  350. }
  351. .spacer {
  352. margin: var(--spacing--2xl);
  353. }
  354. </style>