一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
161 行
4.5 KiB
TypeScript
161 行
4.5 KiB
TypeScript
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;
|