文件
mynode/packages/editor/CodeMirror/extensions/rendering/renderBlockMedia.ts
chen 301194963b
一些检查失败了
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
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

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;