feat: aduio

This commit is contained in:
Philipinho
2026-03-27 22:50:34 +00:00
parent 9f38c61882
commit 6b2f8542c4
15 changed files with 579 additions and 0 deletions
+1
View File
@@ -11,6 +11,7 @@ export * from "./lib/media-utils";
export * from "./lib/link";
export * from "./lib/selection";
export * from "./lib/attachment";
export * from "./lib/audio";
export * from "./lib/custom-code-block";
export * from "./lib/drawio";
export * from "./lib/excalidraw";
@@ -0,0 +1,139 @@
import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types";
import { generateNodeId } from "../utils";
import { Node } from "@tiptap/pm/model";
import { Command } from "@tiptap/core";
const findAudioNodeByPlaceholderId = (
doc: Node,
placeholderId: string,
): { node: Node; pos: number } | null => {
let result: { node: Node; pos: number } | null = null;
doc.descendants((node, pos) => {
if (result) return false;
if (
node.type.name === "audio" &&
node.attrs.placeholder?.id === placeholderId
) {
result = { node, pos };
return false;
}
return true;
});
return result;
};
const handleAudioUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, editor, pos, pageId) => {
const validated = validateFn?.(file);
// @ts-ignore
if (!validated) return;
const objectUrl = URL.createObjectURL(file);
const placeholderId = generateNodeId();
let placeholderInserted = false;
editor.storage.shared.audioPreviews =
editor.storage.shared.audioPreviews || {};
editor.storage.shared.audioPreviews[placeholderId] = objectUrl;
const insertPlaceholder = (): Command => {
return ({ tr, state }) => {
const initialPlaceholderNode = state.schema.nodes.audio?.create({
placeholder: {
id: placeholderId,
name: file.name,
},
});
if (!initialPlaceholderNode) return false;
const { parent } = tr.doc.resolve(pos);
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
if (isEmptyTextBlock) {
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
} else {
tr.insert(pos, initialPlaceholderNode);
}
return true;
};
};
const replacePlaceholderWithAudio = (attachment: IAttachment): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findAudioNodeByPlaceholderId(tr.doc, placeholderId) || {};
if (currentPos === null || !attachment) return;
tr.setNodeMarkup(currentPos, undefined, {
src: `/api/files/${attachment.id}/${attachment.fileName}`,
attachmentId: attachment.id,
size: attachment.fileSize,
});
return true;
};
};
const removePlaceholder = (): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findAudioNodeByPlaceholderId(tr.doc, placeholderId) || {};
if (currentPos === null) return false;
tr.delete(currentPos, currentPos + 2);
return true;
};
};
const insertPlaceholderTimeout = setTimeout(() => {
editor.commands.command(insertPlaceholder());
placeholderInserted = true;
}, 250);
const disposePreviewFile = () => {
URL.revokeObjectURL(objectUrl);
if (editor.storage.shared.audioPreviews) {
delete editor.storage.shared.audioPreviews[placeholderId];
}
};
try {
const attachment: IAttachment = await onUpload(file, pageId);
clearTimeout(insertPlaceholderTimeout);
if (placeholderInserted) {
setTimeout(() => {
editor.commands.command(replacePlaceholderWithAudio(attachment));
disposePreviewFile();
}, 100);
} else {
editor
.chain()
.command(insertPlaceholder())
.command(replacePlaceholderWithAudio(attachment))
.run();
disposePreviewFile();
}
} catch (error) {
clearTimeout(insertPlaceholderTimeout);
editor.commands.command(removePlaceholder());
disposePreviewFile();
}
};
export { handleAudioUpload };
+124
View File
@@ -0,0 +1,124 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { normalizeFileUrl } from "../media-utils";
export interface AudioOptions {
view: any;
HTMLAttributes: Record<string, any>;
}
export interface AudioAttributes {
src?: string;
attachmentId?: string;
size?: number;
placeholder?: {
id: string;
name: string;
};
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
audioBlock: {
setAudio: (attributes: AudioAttributes) => ReturnType;
};
}
}
export const TiptapAudio = Node.create<AudioOptions>({
name: "audio",
group: "block",
isolating: true,
atom: true,
defining: true,
draggable: true,
addOptions() {
return {
view: null,
HTMLAttributes: {},
};
},
addAttributes() {
return {
src: {
default: "",
parseHTML: (element) => element.getAttribute("src"),
renderHTML: (attributes) => ({
src: attributes.src,
}),
},
attachmentId: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-id"),
renderHTML: (attributes: AudioAttributes) => ({
"data-attachment-id": attributes.attachmentId,
}),
},
size: {
default: null,
parseHTML: (element) => element.getAttribute("data-size"),
renderHTML: (attributes: AudioAttributes) => ({
"data-size": attributes.size,
}),
},
placeholder: {
default: null,
rendered: false,
},
};
},
parseHTML() {
return [
{
tag: "audio",
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"audio",
mergeAttributes(
{ controls: "true", preload: "metadata" },
this.options.HTMLAttributes,
HTMLAttributes,
),
["source", { src: HTMLAttributes.src }],
];
},
addCommands() {
return {
setAudio:
(attrs: AudioAttributes) =>
({ commands }) => {
return commands.insertContent({
type: "audio",
attrs: attrs,
});
},
};
},
addNodeView() {
if (this.options.view) {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
}
return ({ node, HTMLAttributes }) => {
const dom = document.createElement("div");
const audio = document.createElement("audio");
audio.src = normalizeFileUrl(node.attrs.src);
audio.controls = true;
audio.preload = "metadata";
audio.style.width = "100%";
dom.append(audio);
return { dom };
};
},
});
@@ -0,0 +1,2 @@
export { TiptapAudio } from "./audio";
export * from "./audio-upload";