mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat(editor): audio and PDF nodes (#2064)
* use local resizable * feat: aduio * support audio imports * feat: use confluence real file names * cleanup * error handling * hide notice * add audio * fix pulse * Fix import and export * unify pulse * hide in readonly mode * keywords * keyword * translations * better sort * feat: PDF embed * cleanup * remove audio menu * open active * hide focus on readonly mode * increase iframe default dimension
This commit is contained in:
@@ -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";
|
||||
@@ -27,3 +28,6 @@ export * from "./lib/shared-storage";
|
||||
export * from "./lib/recreate-transform";
|
||||
export * from "./lib/columns";
|
||||
export * from "./lib/status";
|
||||
export * from "./lib/pdf";
|
||||
export * from "./lib/resizable-nodeview";
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { normalizeFileUrl } from "../media-utils";
|
||||
import { sanitizeUrl, isInternalFileUrl } from "../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) => {
|
||||
const src = element.getAttribute("src");
|
||||
const sanitized = sanitizeUrl(src);
|
||||
return isInternalFileUrl(sanitized) ? sanitized : "";
|
||||
},
|
||||
renderHTML: (attributes) => ({
|
||||
src: isInternalFileUrl(attributes.src)
|
||||
? sanitizeUrl(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");
|
||||
const src = node.attrs.src;
|
||||
if (src && isInternalFileUrl(src)) {
|
||||
audio.src = normalizeFileUrl(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";
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
|
||||
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ResizableNodeView } from "./resizable-nodeview";
|
||||
import type { ResizableNodeViewDirection } from "./resizable-nodeview";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { normalizeFileUrl } from "./media-utils";
|
||||
|
||||
@@ -320,12 +321,11 @@ export const Drawio = Node.create<DrawioOptions>({
|
||||
|
||||
// Show skeleton background while image loads from server
|
||||
dom.style.pointerEvents = "none";
|
||||
dom.style.background =
|
||||
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
|
||||
el.classList.add("media-pulse");
|
||||
|
||||
el.onload = () => {
|
||||
dom.style.pointerEvents = "";
|
||||
dom.style.background = "";
|
||||
el.classList.remove("media-pulse");
|
||||
};
|
||||
|
||||
return nodeView;
|
||||
|
||||
@@ -64,14 +64,14 @@ export const Embed = Node.create<EmbedOptions>({
|
||||
}),
|
||||
},
|
||||
width: {
|
||||
default: 640,
|
||||
default: 800,
|
||||
parseHTML: (element) => element.getAttribute("data-width"),
|
||||
renderHTML: (attributes: EmbedAttributes) => ({
|
||||
"data-width": attributes.width,
|
||||
}),
|
||||
},
|
||||
height: {
|
||||
default: 480,
|
||||
default: 600,
|
||||
parseHTML: (element) => element.getAttribute("data-height"),
|
||||
renderHTML: (attributes: EmbedAttributes) => ({
|
||||
"data-height": attributes.height,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
|
||||
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ResizableNodeView } from "./resizable-nodeview";
|
||||
import type { ResizableNodeViewDirection } from "./resizable-nodeview";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { normalizeFileUrl } from "./media-utils";
|
||||
|
||||
@@ -320,12 +321,11 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
||||
|
||||
// Show skeleton background while image loads from server
|
||||
dom.style.pointerEvents = "none";
|
||||
dom.style.background =
|
||||
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
|
||||
el.classList.add("media-pulse");
|
||||
|
||||
el.onload = () => {
|
||||
dom.style.pointerEvents = "";
|
||||
dom.style.background = "";
|
||||
el.classList.remove("media-pulse");
|
||||
};
|
||||
|
||||
return nodeView;
|
||||
|
||||
@@ -4,10 +4,10 @@ import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import {
|
||||
mergeAttributes,
|
||||
Range,
|
||||
ResizableNodeView,
|
||||
} from "@tiptap/core";
|
||||
import { ResizableNodeView } from "../resizable-nodeview";
|
||||
import type { ResizableNodeViewDirection } from "../resizable-nodeview";
|
||||
import { normalizeFileUrl } from "../media-utils";
|
||||
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||
|
||||
export type ImageResizeOptions = {
|
||||
enabled: boolean;
|
||||
@@ -362,12 +362,11 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
||||
|
||||
// Show skeleton background while image loads from server
|
||||
dom.style.pointerEvents = "none";
|
||||
dom.style.background =
|
||||
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
|
||||
el.classList.add("media-pulse");
|
||||
|
||||
el.onload = () => {
|
||||
dom.style.pointerEvents = "";
|
||||
dom.style.background = "";
|
||||
el.classList.remove("media-pulse");
|
||||
};
|
||||
|
||||
return nodeView;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { TiptapPdf } from "./pdf";
|
||||
export * from "./pdf-upload";
|
||||
@@ -0,0 +1,123 @@
|
||||
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 findPdfNodeByPlaceholderId = (
|
||||
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 === "pdf" &&
|
||||
node.attrs.placeholder?.id === placeholderId
|
||||
) {
|
||||
result = { node, pos };
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const handlePdfUpload =
|
||||
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
|
||||
async (file, editor, pos, pageId) => {
|
||||
const validated = validateFn?.(file);
|
||||
// @ts-ignore
|
||||
if (!validated) return;
|
||||
|
||||
const placeholderId = generateNodeId();
|
||||
|
||||
let placeholderInserted = false;
|
||||
|
||||
const insertPlaceholder = (): Command => {
|
||||
return ({ tr, state }) => {
|
||||
const initialPlaceholderNode = state.schema.nodes.pdf?.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 replacePlaceholderWithPdf = (attachment: IAttachment): Command => {
|
||||
return ({ tr }) => {
|
||||
const { pos: currentPos = null } =
|
||||
findPdfNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||
|
||||
if (currentPos === null || !attachment) return;
|
||||
|
||||
tr.setNodeMarkup(currentPos, undefined, {
|
||||
src: `/api/files/${attachment.id}/${attachment.fileName}`,
|
||||
name: attachment.fileName,
|
||||
attachmentId: attachment.id,
|
||||
size: attachment.fileSize,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
const removePlaceholder = (): Command => {
|
||||
return ({ tr }) => {
|
||||
const { pos: currentPos = null } =
|
||||
findPdfNodeByPlaceholderId(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);
|
||||
|
||||
try {
|
||||
const attachment: IAttachment = await onUpload(file, pageId);
|
||||
|
||||
clearTimeout(insertPlaceholderTimeout);
|
||||
|
||||
if (placeholderInserted) {
|
||||
setTimeout(() => {
|
||||
editor.commands.command(replacePlaceholderWithPdf(attachment));
|
||||
}, 100);
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.command(insertPlaceholder())
|
||||
.command(replacePlaceholderWithPdf(attachment))
|
||||
.run();
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(insertPlaceholderTimeout);
|
||||
editor.commands.command(removePlaceholder());
|
||||
}
|
||||
};
|
||||
|
||||
export { handlePdfUpload };
|
||||
@@ -0,0 +1,156 @@
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { sanitizeUrl, isInternalFileUrl } from "../utils";
|
||||
|
||||
export type PdfOptions = {
|
||||
view: any;
|
||||
HTMLAttributes: Record<string, any>;
|
||||
};
|
||||
|
||||
export type PdfAttributes = {
|
||||
src?: string;
|
||||
name?: string;
|
||||
attachmentId?: string;
|
||||
size?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
placeholder?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
pdfBlock: {
|
||||
setPdf: (attributes: PdfAttributes) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const TiptapPdf = Node.create<PdfOptions>({
|
||||
name: "pdf",
|
||||
|
||||
group: "block",
|
||||
isolating: true,
|
||||
atom: true,
|
||||
defining: true,
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
view: null,
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: "",
|
||||
parseHTML: (element) => {
|
||||
const src = element.getAttribute("src");
|
||||
const sanitized = sanitizeUrl(src);
|
||||
return isInternalFileUrl(sanitized) ? sanitized : "";
|
||||
},
|
||||
renderHTML: (attributes) => ({
|
||||
src: isInternalFileUrl(attributes.src) ? sanitizeUrl(attributes.src) : "",
|
||||
}),
|
||||
},
|
||||
name: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-name"),
|
||||
renderHTML: (attributes: PdfAttributes) => ({
|
||||
"data-name": attributes.name,
|
||||
}),
|
||||
},
|
||||
attachmentId: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-id"),
|
||||
renderHTML: (attributes: PdfAttributes) => ({
|
||||
"data-attachment-id": attributes.attachmentId,
|
||||
}),
|
||||
},
|
||||
size: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-size"),
|
||||
renderHTML: (attributes: PdfAttributes) => ({
|
||||
"data-size": attributes.size,
|
||||
}),
|
||||
},
|
||||
width: {
|
||||
default: 800,
|
||||
parseHTML: (element) => {
|
||||
const raw = element.getAttribute("width");
|
||||
if (!raw) return null;
|
||||
const num = parseFloat(raw);
|
||||
return isNaN(num) ? null : num;
|
||||
},
|
||||
renderHTML: (attributes: PdfAttributes) => ({
|
||||
width: attributes.width,
|
||||
}),
|
||||
},
|
||||
height: {
|
||||
default: 600,
|
||||
parseHTML: (element) => {
|
||||
const raw = element.getAttribute("height");
|
||||
if (!raw) return null;
|
||||
const num = parseFloat(raw);
|
||||
return isNaN(num) ? null : num;
|
||||
},
|
||||
renderHTML: (attributes: PdfAttributes) => ({
|
||||
height: attributes.height,
|
||||
}),
|
||||
},
|
||||
placeholder: {
|
||||
default: null,
|
||||
rendered: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
[
|
||||
"iframe",
|
||||
{
|
||||
src: isInternalFileUrl(HTMLAttributes.src) ? sanitizeUrl(HTMLAttributes.src) : "",
|
||||
width: HTMLAttributes.width || 800,
|
||||
height: HTMLAttributes.height || 600,
|
||||
},
|
||||
],
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setPdf:
|
||||
(attrs: PdfAttributes) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: "pdf",
|
||||
attrs,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
this.editor.isInitialized = true;
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -382,6 +382,12 @@ export function sanitizeUrl(url: string | undefined): string {
|
||||
return sanitized === "about:blank" ? "" : sanitized;
|
||||
}
|
||||
|
||||
export function isInternalFileUrl(url: string | undefined): boolean {
|
||||
if (!url) return false;
|
||||
const normalized = url.trim();
|
||||
return normalized.startsWith("/api/files/") || normalized.startsWith("/files/");
|
||||
}
|
||||
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz";
|
||||
export const generateNodeId = customAlphabet(alphabet, 12);
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { Range, Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
|
||||
import { Range, Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ResizableNodeView } from "../resizable-nodeview";
|
||||
import type { ResizableNodeViewDirection } from "../resizable-nodeview";
|
||||
import { normalizeFileUrl } from "../media-utils";
|
||||
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||
|
||||
export type VideoResizeOptions = {
|
||||
enabled: boolean;
|
||||
@@ -328,12 +329,11 @@ export const TiptapVideo = Node.create<VideoOptions>({
|
||||
|
||||
// Show skeleton background while video loads from server
|
||||
dom.style.pointerEvents = "none";
|
||||
dom.style.background =
|
||||
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
|
||||
el.classList.add("media-pulse");
|
||||
|
||||
el.onloadedmetadata = () => {
|
||||
dom.style.pointerEvents = "";
|
||||
dom.style.background = "";
|
||||
el.classList.remove("media-pulse");
|
||||
};
|
||||
|
||||
return nodeView;
|
||||
|
||||
Reference in New Issue
Block a user