import { Decoration, EditorView, WidgetType } from '@codemirror/view'; import { SyntaxNodeRef } from '@lezer/common'; import { EditorState, Transaction } from '@codemirror/state'; import { RenderedContentContext } from './types'; import makeBlockReplaceExtension from './utils/makeBlockReplaceExtension'; const mediaClassName = 'cm-md-media'; // Matches a line that contains nothing but a link/embed pointing at a Joplin // resource, e.g. "[recording.m4a](:/0123456789abcdef0123456789abcdef)". const resourceLinkLineRegex = /^\s*\[([^\]]*)\]\(:\/([a-zA-Z0-9]{32})\)\s*$/; // Only attachments that look like media get a widget, so that note links, PDFs // and other attachments don't leave an empty block behind while their MIME type // is being resolved. const mediaExtensionRegex = /\.(mp3|m4a|aac|wav|ogg|oga|opus|flac|mp4|m4v|mov|webm|mkv|avi|3gp)$/i; interface ResourceLinkInfo { label: string; resourceId: string; } const parseResourceLinkLine = (node: SyntaxNodeRef, state: EditorState): ResourceLinkInfo|null => { if (node.name !== 'Link') return null; const line = state.doc.lineAt(node.from); if (state.doc.lineAt(node.to).number !== line.number) return null; const match = state.sliceDoc(line.from, line.to).match(resourceLinkLineRegex); if (!match) return null; const label = match[1].trim(); if (!mediaExtensionRegex.test(label)) return null; return { label, resourceId: match[2] }; }; class MediaWidget extends WidgetType { private resolved_: { url: string; mime: string }|null = null; private removed_ = false; public constructor( private readonly context_: RenderedContentContext, private readonly resourceId_: string, private readonly label_: string, ) { super(); } public eq(other: MediaWidget) { return this.resourceId_ === other.resourceId_; } public updateDOM(dom: HTMLElement): boolean { if (this.resolved_) return true; void (async () => { try { const info = await this.context_.resolveResourceInfo?.(`:/${this.resourceId_}`); if (this.removed_ || !info?.mime) { dom.remove(); return; } const mime = info.mime.toLowerCase(); let element: HTMLElement|null = null; if (mime.startsWith('audio/')) { const audio = document.createElement('audio'); audio.controls = true; audio.preload = 'metadata'; element = audio; } else if (mime.startsWith('video/')) { const video = document.createElement('video'); video.controls = true; video.preload = 'metadata'; element = video; } // Not a playable attachment: don't leave an empty block behind. if (!element) { dom.remove(); return; } element.classList.add('media-player'); element.classList.add(mime.startsWith('audio/') ? 'media-audio' : 'media-video'); element.setAttribute('aria-label', this.label_); const source = document.createElement('source'); source.src = info.url; source.type = mime; element.appendChild(source); dom.appendChild(element); this.resolved_ = info; } catch (_error) { dom.remove(); } })(); return true; } public toDOM(_view: EditorView) { const container = document.createElement('div'); container.classList.add(mediaClassName); this.updateDOM(container); return container; } public destroy(dom: HTMLElement) { this.removed_ = true; dom.remove(); } public ignoreEvent() { return true; } } const renderBlockMedia = (context: RenderedContentContext) => [ EditorView.theme({ [`& .${mediaClassName}`]: { paddingTop: '6px', paddingBottom: '6px', }, [`& .${mediaClassName} audio.media-audio`]: { width: '100%', maxWidth: '42rem', display: 'block', }, [`& .${mediaClassName} video.media-video`]: { width: '100%', maxWidth: '920px', display: 'block', borderRadius: '6px', }, }), makeBlockReplaceExtension({ createDecoration: (node, state) => { const info = parseResourceLinkLine(node, state); if (!info) return null; return Decoration.widget({ widget: new MediaWidget(context, info.resourceId, info.label), side: state.doc.lineAt(node.to).number === state.doc.lines ? 1 : -1, block: true, }); }, getDecorationRange: (node, state) => { const nodeLine = state.doc.lineAt(node.to); return [Math.min(nodeLine.to + 1, state.doc.length)]; }, // The player is an extra row below the link, so the raw Markdown must stay // editable and visible at all times. hideWhenContainsSelection: false, shouldFullReRender: (_transaction: Transaction) => false, }), ]; export default renderBlockMedia;