feat: ✨ add video iframe plugin
This commit is contained in:
parent
dac9afe2e6
commit
85db25c72c
14 changed files with 1362 additions and 16 deletions
23
app/Enums/VideoProviderEnum.php
Normal file
23
app/Enums/VideoProviderEnum.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Uwil\LaravelCommon\Traits\Enums\BaseEnum;
|
||||
|
||||
enum VideoProviderEnum: int
|
||||
{
|
||||
use BaseEnum;
|
||||
|
||||
case youtube = 1;
|
||||
case vimeo = 2;
|
||||
|
||||
/**
|
||||
* Optionnal labels definition.
|
||||
*
|
||||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
private const LABELS = [
|
||||
self::youtube->name => 'youtube',
|
||||
self::vimeo->name => 'viméo',
|
||||
];
|
||||
}
|
||||
|
|
@ -295,6 +295,37 @@ return [
|
|||
"cancel" => "Cancel",
|
||||
"tooltip_cancel" => "Cancel the replacement",
|
||||
],
|
||||
"video" => [
|
||||
"tooltip" => "Video",
|
||||
"title" => "Insert an video",
|
||||
"subtitle" => "Add a new video to your content.",
|
||||
"insert" => "Insert",
|
||||
"tooltip_insert" => "Add this video",
|
||||
"cancel" => "Cancel",
|
||||
"tooltip_cancel" => "Cancel adding this video",
|
||||
"edit_title" => "Update a video",
|
||||
"edit_subtitle" => "Edit the attributes associated with the video.",
|
||||
"edit_apply" => "Apply",
|
||||
"edit_tooltip_apply" => "Update the video attributes",
|
||||
"edit_cancel" => "Cancel",
|
||||
"edit_tooltip_cancel" => "Cancel updating the video attributes",
|
||||
"position" => "Image position",
|
||||
"format" => "Image format",
|
||||
"video" => "Image",
|
||||
"alt" => "ALT attribute of the video",
|
||||
"caption" => "Caption of the video",
|
||||
"has_caption" => "Presence of a legend",
|
||||
"landscape" => "Landscape",
|
||||
"portrait" => "Portrait",
|
||||
"square" => "Square",
|
||||
"align-left" => "Align left",
|
||||
"align-center" => "Align center",
|
||||
"align-right" => "Align right",
|
||||
"wrap-left" => "Wrap left",
|
||||
"wrap-right" => "Wrap right",
|
||||
"youtube" => "Youtube",
|
||||
"vimeo" => "Vimeo",
|
||||
],
|
||||
],
|
||||
"json_editor" => [
|
||||
"item_single" => "1 item",
|
||||
|
|
|
|||
|
|
@ -296,6 +296,42 @@ return [
|
|||
"cancel" => "Annuler",
|
||||
"tooltip_cancel" => "Annuler le remplacement",
|
||||
],
|
||||
"video" => [
|
||||
"tooltip" => "Vidéo",
|
||||
"title" => "Insérer une vidéo",
|
||||
"subtitle" => "Ajouter une nouvelle vidéo à votre contenu.",
|
||||
"insert" => "Insérer",
|
||||
"tooltip_insert" => "Ajouter cette vidéo",
|
||||
"cancel" => "Annuler",
|
||||
"tooltip_cancel" => "Annuler l'ajout de cette vidéo",
|
||||
"edit_title" => "Mise à jour d'une vidéo",
|
||||
"edit_subtitle" => "Modifier les attributs associés à la vidéo.",
|
||||
"edit_apply" => "Appliquer",
|
||||
"edit_tooltip_apply" => "Mettre à jour les attributs de la vidéo",
|
||||
"edit_cancel" => "Annuler",
|
||||
"edit_tooltip_cancel" => "Annuler la mise à jour des attributs de la vidéo",
|
||||
"position" => "Position de la vidéo",
|
||||
"format" => "Format de la vidéo",
|
||||
"image" => "Vidéo",
|
||||
"alt" => "Texte alternatif de la vidéo",
|
||||
"caption" => "Légende de la vidéo",
|
||||
"has_caption" => "Présence d'une légende",
|
||||
"landscape" => "Paysage",
|
||||
"portrait" => "Portrait",
|
||||
"align-left" => "Aligner à gauche",
|
||||
"align-center" => "Aligner au centre",
|
||||
"align-right" => "Aligner à droite",
|
||||
"wrap-left" => "Flottant à gauche",
|
||||
"wrap-right" => "Flottant à droite",
|
||||
"youtube" => "Youtube",
|
||||
"vimeo" => "Viméo",
|
||||
"is_muted" => "muter la vidéo ?",
|
||||
"has_controls" => "permettre de controler la vidéo ?",
|
||||
"can_fullScreen" => "autoriser le plein écran ?",
|
||||
"provider" => "hébergeur de la vidéo",
|
||||
'invalid_youtube_id' => "vidéo YOUTUBE invalide",
|
||||
"invalid_vimeo_id" => "vidéo VIMEO invalide"
|
||||
],
|
||||
],
|
||||
"json_editor" => [
|
||||
"item_single" => "1 élément",
|
||||
|
|
|
|||
155
resources/ts/back/Components/Inputs/SimpleVideoUpload.tsx
Normal file
155
resources/ts/back/Components/Inputs/SimpleVideoUpload.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePage } from "@inertiajs/react";
|
||||
import { UploadIcon } from "lucide-react";
|
||||
import {
|
||||
ChangeEvent,
|
||||
MouseEvent,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type Props = {
|
||||
width: number | string;
|
||||
height: number;
|
||||
|
||||
className?: string;
|
||||
name: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
id?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
parentErrors?: Record<string, string> | null;
|
||||
setData: (name: string, value: string) => void;
|
||||
};
|
||||
|
||||
export default function SimpleVideoUpload(props: Props) {
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
className,
|
||||
name,
|
||||
label,
|
||||
id,
|
||||
required,
|
||||
disabled,
|
||||
parentErrors,
|
||||
setData,
|
||||
} = props;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { errors } = usePage<{ errors: Record<string, string> }>().props;
|
||||
const localErrors = parentErrors ?? errors ?? {};
|
||||
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
setSource(url);
|
||||
setFileName(file.name);
|
||||
|
||||
// Important pour Inertia
|
||||
setData(name, file as unknown as string);
|
||||
};
|
||||
|
||||
const handleChoose = (event: React.MouseEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
// Nettoyage mémoire
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (source) {
|
||||
URL.revokeObjectURL(source);
|
||||
}
|
||||
};
|
||||
}, [source]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 w-full",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Label htmlFor={name} className="font-medium">
|
||||
{label}
|
||||
{required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
|
||||
{/* Input caché */}
|
||||
<Input
|
||||
id={id}
|
||||
name={name}
|
||||
type="file"
|
||||
accept=".mov,.mp4"
|
||||
ref={inputRef}
|
||||
onChange={handleFileChange}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Zone preview */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative text-sm flex justify-center border border-dashed overflow-hidden whitespace-normal [&_svg]:!size-auto [&_svg]:!shrink text-muted-foreground w-full h-36",
|
||||
{
|
||||
"border-destructive": localErrors[name],
|
||||
"cursor-pointer": !disabled,
|
||||
}
|
||||
)}
|
||||
style={{ width }}
|
||||
onClick={!disabled ? handleChoose : undefined}
|
||||
>
|
||||
{!source ? (
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<UploadIcon />
|
||||
<p className="text-center break-normal w-full mt-2">
|
||||
Cliquer pour sélectionner une vidéo
|
||||
</p>
|
||||
<small>
|
||||
Format accepté : .mp4, .mov
|
||||
</small>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 w-full items-center">
|
||||
<video
|
||||
className="rounded-md shadow-md max-w-full object-contain"
|
||||
style={{ maxHeight: height }}
|
||||
controls
|
||||
src={source}
|
||||
/>
|
||||
|
||||
<div className="text-sm text-muted-foreground text-center break-all">
|
||||
{fileName}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleChoose}
|
||||
className="text-xs px-3 py-1 rounded-md bg-primary text-primary-foreground hover:opacity-90 transition"
|
||||
>
|
||||
Changer la vidéo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Erreur */}
|
||||
{localErrors[name] && (
|
||||
<p className="text-sm text-destructive">
|
||||
{localErrors[name]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import { Node } from '@tiptap/core';
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { buildVideoSrc } from "./VideoUtils";
|
||||
import VideoNodeView, { FLOAT_STYLES, FloatOption } from "./VideoNodeView";
|
||||
import { CommandProps } from '@tiptap/core';
|
||||
import { VideoAttributes } from './video';
|
||||
|
||||
export const Video = Node.create({
|
||||
name: "video",
|
||||
group: "block",
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
youtubeId: { default: null },
|
||||
vimeoId: { default: null },
|
||||
width: { default: 1008 },
|
||||
height: { default: 567 },
|
||||
caption: { default: null },
|
||||
style: { default: null },
|
||||
float: { default: null },
|
||||
isMuted: { default: false },
|
||||
hasControls: { default: true },
|
||||
canFullScreen: { default: true },
|
||||
};
|
||||
},
|
||||
|
||||
// Parse <figure><iframe .../></figure> back into the node.
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "figure:has(iframe)",
|
||||
getAttrs: (element: HTMLElement) => {
|
||||
const iframe = element.querySelector("iframe") as HTMLIFrameElement;
|
||||
const figcaption = element.querySelector("figcaption");
|
||||
const src = iframe.getAttribute("src") || "";
|
||||
|
||||
const youtubeMatch = src.match(/youtube\.com\/embed\/([a-zA-Z0-9_-]+)/);
|
||||
const vimeoMatch = src.match(/vimeo\.com\/video\/(\d+)/);
|
||||
|
||||
return {
|
||||
youtubeId: youtubeMatch?.[1] ?? null,
|
||||
vimeoId: vimeoMatch?.[1] ?? null,
|
||||
width: parseInt(iframe.getAttribute("width") || "1008"),
|
||||
height: parseInt(iframe.getAttribute("height") || "567"),
|
||||
caption: figcaption?.textContent ?? null,
|
||||
isMuted: src.includes("mute=1") || src.includes("muted=1"),
|
||||
hasControls: !src.includes("controls=0"),
|
||||
canFullScreen: iframe.hasAttribute("allowfullscreen"),
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// Render the node to HTML (used for storage and front-end display).
|
||||
renderHTML({ node }) {
|
||||
const {
|
||||
youtubeId,
|
||||
vimeoId,
|
||||
width,
|
||||
height,
|
||||
caption,
|
||||
style,
|
||||
float,
|
||||
isMuted,
|
||||
hasControls,
|
||||
canFullScreen,
|
||||
} = node.attrs as VideoAttributes;
|
||||
|
||||
const src = buildVideoSrc({ youtubeId, vimeoId, isMuted, hasControls });
|
||||
|
||||
const floatStyle = float ? FLOAT_STYLES[float as FloatOption] : null;
|
||||
const alignStyle = !float && style ? style : null;
|
||||
const finalStyle = [alignStyle, floatStyle].filter(Boolean).join(" ");
|
||||
|
||||
const iframeAttrs: Record<string, string | number> = {
|
||||
src,
|
||||
width: width || 1008,
|
||||
height: height || 567,
|
||||
frameborder: "0",
|
||||
allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
|
||||
};
|
||||
|
||||
if (canFullScreen) {
|
||||
iframeAttrs.allowfullscreen = "true";
|
||||
}
|
||||
|
||||
const figureAttrs: Record<string, string> = {};
|
||||
if (finalStyle) figureAttrs.style = finalStyle;
|
||||
|
||||
if (caption) {
|
||||
return ["figure", figureAttrs, ["iframe", iframeAttrs], ["figcaption", {}, caption]];
|
||||
}
|
||||
|
||||
return ["figure", figureAttrs, ["iframe", iframeAttrs]];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(VideoNodeView);
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setVideo:
|
||||
(attrs: VideoAttributes) =>
|
||||
({ commands }: CommandProps) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs,
|
||||
});
|
||||
},
|
||||
unsetVideo:
|
||||
() =>
|
||||
({ commands }: CommandProps) => {
|
||||
return commands.deleteSelection();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,388 @@
|
|||
// * Packages
|
||||
import { useState } from "react";
|
||||
import { VideoIcon } from "lucide-react";
|
||||
// * Components shadcn
|
||||
import {
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
// * Other
|
||||
import {
|
||||
videoDetails,
|
||||
extractYoutubeId,
|
||||
extractVimeoId,
|
||||
videoTextSchema,
|
||||
isBoolsSchema,
|
||||
} from "@/back/Components/Inputs/Wysiwyg/Extensions/Video/VideoUtils";
|
||||
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
import str from "@/hooks/use-string";
|
||||
import VideoInputs from "./VideoInputs";
|
||||
import SimpleVimeoId from "../../../SimpleVimeoId";
|
||||
import SimpleYoutubeId from "../../../SimpleYoutubeId";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { GetTranslation } from "@/lib/utils";
|
||||
import z from "zod";
|
||||
|
||||
|
||||
export type VideoProviderType = "youtube" | "vimeo";
|
||||
|
||||
export type InputVideoValues = {
|
||||
caption: string | null;
|
||||
hasCaption: boolean;
|
||||
isMuted: boolean;
|
||||
hasControls: boolean;
|
||||
canFullScreen: boolean;
|
||||
};
|
||||
|
||||
type VideoFormatOption = {
|
||||
name: "landscape" | "portrait";
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type VideoProps = {
|
||||
editor: Editor;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
const VIDEO_FORMATS: VideoFormatOption[] = [
|
||||
{ name: "landscape", width: Number(videoDetails.width), height: Number(videoDetails.height) },
|
||||
{ name: "portrait", width: Number(videoDetails.height), height: Number(videoDetails.width) },
|
||||
];
|
||||
|
||||
const DEFAULT_OPTIONS: InputVideoValues = {
|
||||
caption: null,
|
||||
hasCaption: false,
|
||||
isMuted: false,
|
||||
hasControls: true,
|
||||
canFullScreen: true,
|
||||
};
|
||||
|
||||
const animationTime = 200;
|
||||
|
||||
export default function VideoButton({ editor, disabled }: VideoProps) {
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [videoProvider, setVideoProvider] = useState<VideoProviderType>("youtube");
|
||||
const [videoUrl, setVideoUrl] = useState<string>("");
|
||||
const [inputFormat, setInputFormat] = useState<VideoFormatOption>(VIDEO_FORMATS[0]);
|
||||
const [options, setOptions] = useState<InputVideoValues>(DEFAULT_OPTIONS);
|
||||
|
||||
const resetComponent = () => {
|
||||
setVideoUrl("");
|
||||
setOptions(DEFAULT_OPTIONS);
|
||||
setVideoProvider("youtube");
|
||||
setInputFormat(VIDEO_FORMATS[0]);
|
||||
setErrors({});
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
//* TRANSLATIONS
|
||||
const transCaption = GetTranslation("back.wysiwyg.video.caption");
|
||||
const transIsMuted = GetTranslation("back.wysiwyg.video.is_muted");
|
||||
const transHasControls = GetTranslation("back.wysiwyg.video.has_controls");
|
||||
const transCanFullScreen = GetTranslation("back.wysiwyg.video.can_fullScreen");
|
||||
const youtubeError = str(GetTranslation("back.wysiwyg.video.invalid_youtube_id")).ucFirst().value();
|
||||
const vimeoError = str(GetTranslation("back.wysiwyg.video.invalid_vimeo_id")).ucFirst().value();
|
||||
|
||||
const transValidation = (attribute: string) => {
|
||||
return {
|
||||
min: GetTranslation("validation.min.string", {
|
||||
params: { attribute, min: "3" },
|
||||
}),
|
||||
max: GetTranslation("validation.max.string", {
|
||||
params: { attribute, max: "255" },
|
||||
}),
|
||||
string: GetTranslation("validation.string", {
|
||||
params: { attribute },
|
||||
}),
|
||||
boolean: GetTranslation("validation.boolean", {
|
||||
params: { attribute }
|
||||
})
|
||||
};
|
||||
};
|
||||
const transValidationCaption = transValidation(transCaption);
|
||||
const transValidationIsMuted = transValidation(transIsMuted);
|
||||
const transValidationHasControls = transValidation(transHasControls);
|
||||
const transValidationCanFullScreen = transValidation(transCanFullScreen);
|
||||
|
||||
/**
|
||||
* Set new errors from object errors
|
||||
*
|
||||
* @param err unknown
|
||||
* @return void
|
||||
*/
|
||||
const setNewErrors = (err: unknown): void => {
|
||||
const fieldErrors: typeof errors = {};
|
||||
const fieldMapping: Record<string, string> = {
|
||||
caption: "caption",
|
||||
isMuted: "isMuted",
|
||||
hasControls: "hasControls",
|
||||
canFullScreen: "canFullScreen"
|
||||
};
|
||||
// Manage zod errors.
|
||||
if (err instanceof z.ZodError) {
|
||||
err.issues.forEach((error) => {
|
||||
const key = error.path[0] as string | undefined;
|
||||
const mappedKey =
|
||||
(key && fieldMapping[key]) ??
|
||||
(error.path.length === 0 ? "form" : undefined);
|
||||
if (mappedKey) fieldErrors[mappedKey] = error.message;
|
||||
});
|
||||
|
||||
setErrors(fieldErrors);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddVideo = (): void => {
|
||||
setLoading(true);
|
||||
setErrors({});
|
||||
|
||||
const videoMetadataSchema = videoTextSchema({
|
||||
hasCaption: options.hasCaption,
|
||||
transValidationCaption: transValidationCaption
|
||||
});
|
||||
|
||||
const isBoolsSchemaValidation = isBoolsSchema({
|
||||
canFullScreen: transValidationCanFullScreen.boolean,
|
||||
isMuted: transValidationIsMuted.boolean,
|
||||
hasControls: transValidationHasControls.boolean
|
||||
});
|
||||
|
||||
const videoData = {
|
||||
caption: options.caption,
|
||||
isMuted: options.isMuted,
|
||||
hasControls: options.hasControls,
|
||||
canFullScreen: options.canFullScreen,
|
||||
url: videoUrl
|
||||
};
|
||||
|
||||
let youtubeId: string | null = null;
|
||||
let vimeoId: string | null = null;
|
||||
|
||||
try {
|
||||
videoMetadataSchema.parse(videoData);
|
||||
isBoolsSchemaValidation.parse(videoData);
|
||||
if (videoProvider === "youtube") {
|
||||
youtubeId = extractYoutubeId(videoUrl);
|
||||
if (!youtubeId) {
|
||||
setErrors({ url: youtubeError});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
vimeoId = extractVimeoId(videoUrl);
|
||||
if (!vimeoId) {
|
||||
setErrors({ url: vimeoError});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
editor.commands.setVideo({
|
||||
youtubeId,
|
||||
vimeoId,
|
||||
width: inputFormat.width,
|
||||
height: inputFormat.height,
|
||||
caption: options.hasCaption ? options.caption : null,
|
||||
isMuted: options.isMuted,
|
||||
hasControls: options.hasControls,
|
||||
canFullScreen: options.canFullScreen,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
setTimeout(() => resetComponent(), animationTime);
|
||||
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
setNewErrors(err);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleCancel = (): void => {
|
||||
setOpen(false);
|
||||
setTimeout(() => resetComponent(), animationTime);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(status) => {
|
||||
setOpen(status);
|
||||
if (!status) setTimeout(() => resetComponent(), animationTime);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
content={<VideoIcon className="w-4 h-4 m-auto" />}
|
||||
tooltipContent={str(useTrans("back.wysiwyg.video.tooltip")).ucFirst().value()}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
/>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="p-4">
|
||||
<DialogHeader className="px-2 pt-2">
|
||||
<DialogTitle>
|
||||
{useTrans("back.wysiwyg.video.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{useTrans("back.wysiwyg.video.subtitle")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="overflow-y-auto max-h-[70vh] flex flex-col gap-4 px-2">
|
||||
{/* Provider selection */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>
|
||||
{str(useTrans("back.wysiwyg.video.provider")).ucFirst().value()}
|
||||
</Label>
|
||||
<div className="flex gap-4">
|
||||
{(["youtube", "vimeo"] as VideoProviderType[]).map((provider) => (
|
||||
<Button
|
||||
key={provider}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setVideoProvider(provider);
|
||||
setVideoUrl("");
|
||||
setErrors({});
|
||||
}}
|
||||
variant={videoProvider === provider ? "default" : "secondary"}
|
||||
className="flex-1 flex flex-col items-center gap-2 h-fit py-3 [&_svg]:size-8"
|
||||
>
|
||||
{provider === "youtube" ? <YoutubeIcon /> : <VimeoIcon />}
|
||||
<span className="text-sm font-medium capitalize">{provider}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL / ID input */}
|
||||
{videoProvider === "youtube" ? (
|
||||
<SimpleYoutubeId
|
||||
name="url"
|
||||
value={videoUrl}
|
||||
setData={(_, v) => {
|
||||
setErrors((prev) => ({ ...prev, url: "" }));
|
||||
setVideoUrl(v as string);
|
||||
}}
|
||||
required={false}
|
||||
parentErrors={errors}
|
||||
disabled={loading}
|
||||
/>
|
||||
) : (
|
||||
<SimpleVimeoId
|
||||
name="url"
|
||||
value={videoUrl}
|
||||
setData={(_, v) => {
|
||||
setErrors((prev) => ({ ...prev, url: "" }));
|
||||
setVideoUrl(v as string);
|
||||
}}
|
||||
required={false}
|
||||
parentErrors={errors}
|
||||
disabled={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Format selection */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>
|
||||
{str(useTrans("back.wysiwyg.video.format")).ucFirst().value()}
|
||||
</Label>
|
||||
<div className="flex gap-4">
|
||||
{VIDEO_FORMATS.map((format) => (
|
||||
<Button
|
||||
key={format.name}
|
||||
type="button"
|
||||
onClick={() => setInputFormat(format)}
|
||||
variant={inputFormat.name === format.name ? "default" : "secondary"}
|
||||
className="flex-1 flex flex-col items-center gap-2 h-fit py-3"
|
||||
>
|
||||
<div className="w-16 h-12 flex items-center justify-center">
|
||||
<div
|
||||
className={
|
||||
format.name === "landscape"
|
||||
? "w-14 h-8 bg-current opacity-50 rounded"
|
||||
: "w-8 h-12 bg-current opacity-50 rounded"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{str(useTrans(`back.wysiwyg.video.${format.name}`)).ucFirst().value()}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options: caption, muted, controls, fullscreen */}
|
||||
<VideoInputs
|
||||
value={options}
|
||||
onChange={setOptions}
|
||||
disabled={loading}
|
||||
errors={errors}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="gap-1.5 px-2 pb-2">
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
content={str(useTrans("back.wysiwyg.video.cancel")).ucFirst().value()}
|
||||
tooltipContent={useTrans("back.wysiwyg.video.tooltip_cancel")}
|
||||
className="cursor-pointer"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
size="default"
|
||||
disabled={loading}
|
||||
onClick={handleCancel}
|
||||
/>
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
content={str(useTrans("back.wysiwyg.video.insert")).ucFirst().value()}
|
||||
tooltipContent={useTrans("back.wysiwyg.video.tooltip_insert")}
|
||||
className="cursor-pointer"
|
||||
type="button"
|
||||
size="default"
|
||||
disabled={loading || !videoUrl.trim()}
|
||||
onClick={handleAddVideo}
|
||||
/>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function YoutubeIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
fill="#FF0000"
|
||||
d="M23.498 6.186a2.998 2.998 0 0 0-2.11-2.117C19.66 3.5 12 3.5 12 3.5s-7.66 0-9.388.569A2.998 2.998 0 0 0 .502 6.186 31.198 31.198 0 0 0 0 12a31.198 31.198 0 0 0 .502 5.814 2.998 2.998 0 0 0 2.11 2.117C4.34 20.5 12 20.5 12 20.5s7.66 0 9.388-.569a2.998 2.998 0 0 0 2.11-2.117A31.198 31.198 0 0 0 24 12a31.198 31.198 0 0 0-.502-5.814z"
|
||||
/>
|
||||
<path fill="#FFF" d="M9.545 15.568l6.364-3.568-6.364-3.568v7.136z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function VimeoIcon() {
|
||||
return (
|
||||
<svg viewBox="0 -3.5 48 48" xmlns="http://www.w3.org/2000/svg" fill="none">
|
||||
<path
|
||||
fill="#00B2EA"
|
||||
d="M47.975851,9.479329 C47.767002,14.100014 44.498808,20.41644 38.193846,28.431401 C31.668746,36.81233 26.13991,41 21.621448,41 C18.827396,41 16.459507,38.452198 14.526249,33.339832 C13.230825,28.649305 11.943867,23.958779 10.651265,19.282221 C9.211905,14.167061 7.670943,11.610878 6.022735,11.610878 C5.664306,11.610878 4.414038,12.356781 2.25782,13.85138 L0,10.971134 C2.365066,8.917807 4.696265,6.856098 6.996419,4.799977 C10.146078,2.101318 12.513967,0.684941 14.094441,0.536878 C17.819844,0.179292 20.117175,2.701951 20.983614,8.096476 C21.906498,13.921221 22.555621,17.541782 22.91405,18.960952 C23.992159,23.788367 25.17187,26.196487 26.464472,26.196487 C27.466379,26.196487 28.973474,24.637634 30.982934,21.517135 C32.992393,18.391049 34.062036,16.016453 34.208794,14.379378 C34.496666,11.680719 33.421379,10.339771 30.982934,10.339771 C29.834268,10.339771 28.648912,10.580024 27.432512,11.08288 C29.803223,3.486965 34.318863,-0.206231 40.99072,0.00888 C45.932524,0.145768 48.266545,3.308172 47.975851,9.479329"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
// * Other
|
||||
import SimpleString from "@/back/Components/Inputs/SimpleString";
|
||||
import SimpleSwitch from "@/back/Components/Inputs/SimpleSwitch";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
import str from "@/hooks/use-string";
|
||||
import { InputVideoValues } from "./VideoButton";
|
||||
|
||||
type VideoInputsProps = {
|
||||
value: InputVideoValues;
|
||||
onChange: (value: InputVideoValues) => void;
|
||||
errors?: Record<string, string>;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export default function VideoInputs({
|
||||
value,
|
||||
onChange,
|
||||
errors = {},
|
||||
disabled = false,
|
||||
loading = false
|
||||
}: VideoInputsProps) {
|
||||
const transVideoCaption = useTrans("back.wysiwyg.video.caption");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 p-6 border rounded-md">
|
||||
{/* Input switch to set the caption visibility. */}
|
||||
<SimpleSwitch
|
||||
label={str(useTrans("back.wysiwyg.video.has_caption"))
|
||||
.ucFirst()
|
||||
.value()}
|
||||
leftHelper={str(useTrans("back.no")).ucFirst().value()}
|
||||
rightHelper={str(useTrans("back.yes")).ucFirst().value()}
|
||||
name="hasCaption"
|
||||
checked={value.hasCaption}
|
||||
className="justify-center"
|
||||
setData={(_, v) =>
|
||||
onChange({
|
||||
...value,
|
||||
hasCaption: v,
|
||||
caption: v ? value.caption : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{/* Input caption of the video. */}
|
||||
{value.hasCaption && (
|
||||
<SimpleString
|
||||
name="caption"
|
||||
label={str(transVideoCaption).ucFirst().value()}
|
||||
placeholder="ex: John Doe"
|
||||
required={false}
|
||||
value={value.caption ?? ""}
|
||||
setData={(_, v) => onChange({ ...value, caption: v })}
|
||||
disabled={disabled || loading}
|
||||
parentErrors={errors}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SimpleSwitch
|
||||
label={str(useTrans("back.wysiwyg.video.is_muted"))
|
||||
.ucFirst()
|
||||
.value()}
|
||||
leftHelper={str(useTrans("back.no")).ucFirst().value()}
|
||||
rightHelper={str(useTrans("back.yes")).ucFirst().value()}
|
||||
name="isMuted"
|
||||
checked={value.isMuted}
|
||||
className="justify-center"
|
||||
setData={(_, v) =>
|
||||
onChange({
|
||||
...value,
|
||||
isMuted: v,
|
||||
})
|
||||
}
|
||||
parentErrors={errors}
|
||||
/>
|
||||
|
||||
<SimpleSwitch
|
||||
label={str(useTrans("back.wysiwyg.video.has_controls"))
|
||||
.ucFirst()
|
||||
.value()}
|
||||
leftHelper={str(useTrans("back.no")).ucFirst().value()}
|
||||
rightHelper={str(useTrans("back.yes")).ucFirst().value()}
|
||||
name="hasControls"
|
||||
checked={value.hasControls}
|
||||
className="justify-center"
|
||||
setData={(_, v) =>
|
||||
onChange({
|
||||
...value,
|
||||
hasControls: v,
|
||||
})
|
||||
}
|
||||
parentErrors={errors}
|
||||
/>
|
||||
|
||||
<SimpleSwitch
|
||||
label={str(useTrans("back.wysiwyg.video.can_fullScreen"))
|
||||
.ucFirst()
|
||||
.value()}
|
||||
leftHelper={str(useTrans("back.no")).ucFirst().value()}
|
||||
rightHelper={str(useTrans("back.yes")).ucFirst().value()}
|
||||
name="canFullScreen"
|
||||
checked={value.canFullScreen}
|
||||
className="justify-center"
|
||||
setData={(_, v) =>
|
||||
onChange({
|
||||
...value,
|
||||
canFullScreen: v,
|
||||
})
|
||||
}
|
||||
parentErrors={errors}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,334 @@
|
|||
// * Packages
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import {
|
||||
MoveDiagonal2Icon,
|
||||
AlignLeftIcon,
|
||||
AlignCenterIcon,
|
||||
AlignRightIcon,
|
||||
ArrowLeftRightIcon,
|
||||
ArrowRightLeftIcon,
|
||||
LayoutIcon,
|
||||
PencilIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
// * Components shadcn
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
// * Other
|
||||
import {
|
||||
buildVideoSrc,
|
||||
videoDetails,
|
||||
} from "@/back/Components/Inputs/Wysiwyg/Extensions/Video/VideoUtils";
|
||||
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
import str from "@/hooks/use-string";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseStyleString } from "@/hooks/use-inline-style";
|
||||
import VideoInputs from "@/back/Components/Inputs/Wysiwyg/Extensions/Video/VideoInputs";
|
||||
import { InputVideoValues } from "./VideoButton";
|
||||
import { VideoAttributes } from "./video";
|
||||
|
||||
export type AllowedAligns = "left" | "center" | "right";
|
||||
|
||||
export type FloatOption = "left" | "right";
|
||||
|
||||
export const ALIGN_STYLES: Record<AllowedAligns, string> = {
|
||||
left: "margin: 0 auto 0 0; width: fit-content;",
|
||||
center: "margin: 0 auto 0 auto; width: fit-content;",
|
||||
right: "margin: 0 0 0 auto; width: fit-content;",
|
||||
};
|
||||
|
||||
export const FLOAT_STYLES: Record<FloatOption, string> = {
|
||||
left: "float: left; margin: 0 1rem 0 0;",
|
||||
right: "float: right; margin: 0 0 0 1rem;",
|
||||
};
|
||||
|
||||
type PlacementValue =
|
||||
| "align-left"
|
||||
| "align-center"
|
||||
| "align-right"
|
||||
| "float-left"
|
||||
| "float-right";
|
||||
|
||||
const PLACEMENT_OPTIONS: PlacementValue[] = [
|
||||
"align-left",
|
||||
"align-center",
|
||||
"align-right",
|
||||
"float-left",
|
||||
"float-right",
|
||||
];
|
||||
|
||||
const PLACEMENT_STYLES: Record<PlacementValue, string> = {
|
||||
"align-left": ALIGN_STYLES.left,
|
||||
"align-center": ALIGN_STYLES.center,
|
||||
"align-right": ALIGN_STYLES.right,
|
||||
"float-left": FLOAT_STYLES.left,
|
||||
"float-right": FLOAT_STYLES.right,
|
||||
};
|
||||
|
||||
|
||||
export default function VideoNodeView(props: NodeViewProps) {
|
||||
const { node, updateAttributes, editor, selected } = props;
|
||||
const attrs = node.attrs as VideoAttributes;
|
||||
|
||||
// Wrapper div used to measure size for resize
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const resizeState = useRef<{ startX: number; startWidth: number; ratio: number } | null>(null);
|
||||
const minSize = Number(videoDetails.minDim);
|
||||
const maxSize = Number(videoDetails.maxDim);
|
||||
const [topSticky, setTopSticky] = useState<number>(0);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputValues, setInputValues] = useState<InputVideoValues>({
|
||||
caption: attrs.caption,
|
||||
hasCaption: !!attrs.caption,
|
||||
isMuted: attrs.isMuted,
|
||||
hasControls: attrs.hasControls,
|
||||
canFullScreen: attrs.canFullScreen,
|
||||
});
|
||||
|
||||
// Rebuild iframe src when attrs change (e.g. after edit dialog save)
|
||||
const src = buildVideoSrc({
|
||||
youtubeId: attrs.youtubeId,
|
||||
vimeoId: attrs.vimeoId,
|
||||
isMuted: attrs.isMuted,
|
||||
hasControls: attrs.hasControls,
|
||||
});
|
||||
|
||||
const getCurrentPlacement = (): PlacementValue | null => {
|
||||
if (attrs.float === "left") return "float-left";
|
||||
if (attrs.float === "right") return "float-right";
|
||||
if (attrs.style?.includes(ALIGN_STYLES.left)) return "align-left";
|
||||
if (attrs.style?.includes(ALIGN_STYLES.center)) return "align-center";
|
||||
if (attrs.style?.includes(ALIGN_STYLES.right)) return "align-right";
|
||||
return null;
|
||||
};
|
||||
|
||||
const handlePlacementChange = (value: PlacementValue) => {
|
||||
updateAttributes({
|
||||
style: PLACEMENT_STYLES[value],
|
||||
float: value.startsWith("float-") ? (value.split("-")[1] as FloatOption) : null,
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseDown = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
resizeState.current = {
|
||||
startX: event.clientX,
|
||||
startWidth: wrapper.offsetWidth,
|
||||
ratio: (attrs.width || Number(videoDetails.width)) / (attrs.height || Number(videoDetails.height)),
|
||||
};
|
||||
setIsResizing(true);
|
||||
};
|
||||
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
if (!isResizing || !resizeState.current) return;
|
||||
const { startX, startWidth, ratio } = resizeState.current;
|
||||
let newWidth = startWidth + (event.clientX - startX);
|
||||
let newHeight = newWidth / ratio;
|
||||
if (newWidth < minSize) { newWidth = minSize; newHeight = newWidth / ratio; }
|
||||
if (newHeight < minSize) { newHeight = minSize; newWidth = newHeight * ratio; }
|
||||
if (newWidth > maxSize) { newWidth = maxSize; newHeight = newWidth / ratio; }
|
||||
if (newHeight > maxSize) { newHeight = maxSize; newWidth = newHeight * ratio; }
|
||||
updateAttributes({ width: Math.round(newWidth), height: Math.round(newHeight) });
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const currentHeaderHeight =
|
||||
document.querySelector("header")?.clientHeight ?? 0;
|
||||
const currentToolbarHeight =
|
||||
document.querySelectorAll(".tiptap-toolbar")[0].clientHeight ??
|
||||
0;
|
||||
setTopSticky(currentHeaderHeight + currentToolbarHeight);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
handleResize();
|
||||
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
const onMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
resizeState.current = null;
|
||||
};
|
||||
|
||||
const saveDialog = () => {
|
||||
updateAttributes({
|
||||
caption: inputValues.hasCaption ? inputValues.caption : null,
|
||||
isMuted: inputValues.isMuted,
|
||||
hasControls: inputValues.hasControls,
|
||||
canFullScreen: inputValues.canFullScreen,
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return;
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isResizing]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
ref={wrapperRef}
|
||||
as="figure"
|
||||
data-type="video"
|
||||
style={parseStyleString(attrs.style)}
|
||||
className={cn("relative w-fit", {
|
||||
"outline outline-2 outline-dashed": selected,
|
||||
})}
|
||||
>
|
||||
{selected && (
|
||||
<div
|
||||
className="sticky"
|
||||
style={{
|
||||
top: topSticky,
|
||||
}}
|
||||
>
|
||||
<span className="absolute top-2 left-2 bg-background backdrop-blur-sm rounded-md border shadow-md m-0 p-1">
|
||||
{`${attrs.width || videoDetails.width}x${attrs.height || videoDetails.height}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
style={{
|
||||
width: attrs.width || Number(videoDetails.width),
|
||||
height: attrs.height || Number(videoDetails.height),
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src={src}
|
||||
width={attrs.width || Number(videoDetails.width)}
|
||||
height={attrs.height || Number(videoDetails.height)}
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen={attrs.canFullScreen}
|
||||
title={attrs.caption || "video"}
|
||||
// Disable pointer events when selected so clicks go to the editor
|
||||
style={{ pointerEvents: selected ? "none" : "auto", display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="absolute bottom-2 right-2 flex flex-col gap-1 p-1 bg-background/90 backdrop-blur-sm rounded-md border shadow-md">
|
||||
{/* Placement dropdown */}
|
||||
<Select
|
||||
value={getCurrentPlacement() || undefined}
|
||||
onValueChange={handlePlacementChange}
|
||||
>
|
||||
<SelectTrigger className="w-10 h-10 hover:bg-accent hover:text-accent-foreground p-0">
|
||||
<LayoutIcon className="w-4 h-4 mx-auto" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
{PLACEMENT_OPTIONS.map((value) => {
|
||||
const [type, dir] = value.split("-") as ["align" | "float", string];
|
||||
const label = type === "align" ? `Align ${dir}` : `Wrap ${dir}`;
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{value === "align-left" && <AlignLeftIcon className="w-4 h-4" />}
|
||||
{value === "align-center" && <AlignCenterIcon className="w-4 h-4" />}
|
||||
{value === "align-right" && <AlignRightIcon className="w-4 h-4" />}
|
||||
{value === "float-left" && <ArrowLeftRightIcon className="w-4 h-4" />}
|
||||
{value === "float-right" && <ArrowRightLeftIcon className="w-4 h-4" />}
|
||||
{label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Edit button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
{/* Resize button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onMouseDown={onMouseDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn("cursor-nw-resize", {
|
||||
"bg-accent text-accent-foreground": isResizing,
|
||||
})}
|
||||
>
|
||||
<MoveDiagonal2Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{attrs.caption && <figcaption>{attrs.caption}</figcaption>}
|
||||
{/* Edit dialog */}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="p-4">
|
||||
<DialogHeader className="px-2 pt-2">
|
||||
<DialogTitle>
|
||||
{useTrans("back.wysiwyg.video.edit_title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{useTrans("back.wysiwyg.video.edit_subtitle")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="overflow-y-auto max-h-[70vh] flex flex-col gap-4 px-2">
|
||||
<VideoInputs
|
||||
value={inputValues}
|
||||
onChange={setInputValues}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="gap-1.5 px-2 pb-2">
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
content={useTrans("back.wysiwyg.video.edit_cancel")}
|
||||
tooltipContent={str(useTrans("back.wysiwyg.video.edit_tooltip_cancel")).ucFirst().value()}
|
||||
onClick={() => setOpen(false)}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="default"
|
||||
/>
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
content={useTrans("back.wysiwyg.video.edit_apply")}
|
||||
tooltipContent={str(useTrans("back.wysiwyg.video.edit_tooltip_apply")).ucFirst().value()}
|
||||
onClick={saveDialog}
|
||||
type="button"
|
||||
variant="default"
|
||||
size="default"
|
||||
/>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const videoDetails = {
|
||||
width: "1008",
|
||||
height: "567",
|
||||
minDim: "100",
|
||||
maxDim: "1008",
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract YouTube video ID from a URL or a raw ID.
|
||||
*/
|
||||
export const extractYoutubeId = (input: string): string | null => {
|
||||
const trimmed = input.trim();
|
||||
// Direct ID (11 chars)
|
||||
if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed)) return trimmed;
|
||||
// youtube.com/watch?v=ID
|
||||
const watchMatch = trimmed.match(/[?&]v=([a-zA-Z0-9_-]{11})/);
|
||||
if (watchMatch) return watchMatch[1];
|
||||
// youtu.be/ID
|
||||
const shortMatch = trimmed.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/);
|
||||
if (shortMatch) return shortMatch[1];
|
||||
// youtube.com/embed/ID
|
||||
const embedMatch = trimmed.match(/youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/);
|
||||
if (embedMatch) return embedMatch[1];
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract Vimeo video ID from a URL or a raw ID.
|
||||
*/
|
||||
export const extractVimeoId = (input: string): string | null => {
|
||||
const trimmed = input.trim();
|
||||
// Direct numeric ID
|
||||
if (/^\d+$/.test(trimmed)) return trimmed;
|
||||
// vimeo.com/ID or vimeo.com/video/ID
|
||||
const match = trimmed.match(/vimeo\.com\/(?:video\/)?(\d+)/);
|
||||
if (match) return match[1];
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the iframe src URL for YouTube or Vimeo with options.
|
||||
*/
|
||||
export const buildVideoSrc = (attrs: {
|
||||
youtubeId?: string | null;
|
||||
vimeoId?: string | null;
|
||||
isMuted?: boolean;
|
||||
hasControls?: boolean;
|
||||
}): string => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (attrs.youtubeId) {
|
||||
if (attrs.isMuted) params.set("mute", "1");
|
||||
params.set("controls", attrs.hasControls ? "1" : "0");
|
||||
const query = params.toString();
|
||||
return `https://www.youtube.com/embed/${attrs.youtubeId}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
if (attrs.vimeoId) {
|
||||
if (attrs.isMuted) params.set("muted", "1");
|
||||
if (!attrs.hasControls) params.set("controls", "0");
|
||||
const query = params.toString();
|
||||
return `https://player.vimeo.com/video/${attrs.vimeoId}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Zod schema for video caption input.
|
||||
*/
|
||||
export const videoTextSchema = ({
|
||||
hasCaption,
|
||||
transValidationCaption,
|
||||
}: {
|
||||
hasCaption: boolean;
|
||||
transValidationCaption: { min: string; max: string; string: string, boolean: string };
|
||||
}) => {
|
||||
const captionRules = z
|
||||
.string({ message: transValidationCaption.string })
|
||||
.min(3, transValidationCaption.min)
|
||||
.max(255, transValidationCaption.max);
|
||||
|
||||
const isBool = z.boolean({ message: transValidationCaption.boolean });
|
||||
|
||||
return z.object({
|
||||
caption: hasCaption ? captionRules : z.null(),
|
||||
isMuted: isBool,
|
||||
hasControls: isBool,
|
||||
canFullScreen: isBool,
|
||||
});
|
||||
};
|
||||
|
||||
export const isBoolsSchema = (transValidation: { isMuted: string; hasControls: string; canFullScreen: string }) => {
|
||||
const isMuted = z.boolean({ message: transValidation.isMuted });
|
||||
const hasControls = z.boolean({ message: transValidation.hasControls });
|
||||
const canFullScreen = z.boolean({ message: transValidation.canFullScreen });
|
||||
|
||||
return z.object({
|
||||
isMuted: isMuted,
|
||||
hasControls: hasControls,
|
||||
canFullScreen: canFullScreen,
|
||||
});
|
||||
};
|
||||
24
resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Video/video.d.ts
vendored
Normal file
24
resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Video/video.d.ts
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// * Packages
|
||||
import "@tiptap/core";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
Video: {
|
||||
setVideo: (attrs: VideoAttributes) => ReturnType;
|
||||
unsetVideo: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface VideoAttributes {
|
||||
youtubeId: string | null;
|
||||
vimeoId: string | null;
|
||||
width: number;
|
||||
style?: string | null;
|
||||
float?: string | null;
|
||||
height: number;
|
||||
caption: string | null;
|
||||
isMuted: boolean;
|
||||
hasControls: boolean;
|
||||
canFullScreen: boolean;
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import { Label } from "@/components/ui/label";
|
|||
import ShortcutBoundary from "@/back/Components/Inputs/Wysiwyg/Extensions/ShortCuts/ShortcutBoundary";
|
||||
import { Link } from "@/back/Components/Inputs/Wysiwyg/Extensions/Link/Link";
|
||||
import Image from "@/back/Components/Inputs/Wysiwyg/Extensions/Image/Image";
|
||||
import {Video} from "@/back/Components/Inputs/Wysiwyg/Extensions/Video/Video";
|
||||
import InputError from "@/back/Components/InputError";
|
||||
import WordCounter from "@/back/Components/WordCounter";
|
||||
import Toolbar from "@/back/Components/Inputs/Wysiwyg/Toolbar";
|
||||
|
|
@ -118,6 +119,7 @@ export default function SimpleWysiwyg({
|
|||
}),
|
||||
Image,
|
||||
Link,
|
||||
Video
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import FindReplaceButton from "@/back/Components/Inputs/Wysiwyg/Extensions/Repla
|
|||
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
|
||||
import str from "@/hooks/use-string";
|
||||
import { cn, GetTranslation } from "@/lib/utils";
|
||||
import VideoButton from "./Extensions/Video/VideoButton";
|
||||
|
||||
type AllowedHeadings = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
|
|
@ -435,6 +436,7 @@ export default function Toolbar({ editor }: ToolbarProps) {
|
|||
<TableButton editor={editor} disabled={docSelected} />
|
||||
<ImageButton editor={editor} disabled={docSelected} />
|
||||
<LinkButton editor={editor} disabled={docSelected} />
|
||||
<VideoButton editor={editor} disabled={docSelected} />
|
||||
<Separator orientation="vertical" className="!h-auto" />
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
content={<PilcrowIcon className="w-4 h-4 m-auto" />}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import useTrans from "@/hooks/use-translations";
|
|||
import str from "@/hooks/use-string";
|
||||
import { extractIdsFromDatabasePivotRelation } from "@/lib/mapPivotOptions";
|
||||
import useCrudSubmit from "@/hooks/use-crud-submit";
|
||||
import SimpleVideoUpload from "@/back/Components/Inputs/SimpleVideoUpload";
|
||||
|
||||
export default function Form({
|
||||
model,
|
||||
|
|
@ -241,23 +242,32 @@ export default function Form({
|
|||
setData={setData}
|
||||
/>
|
||||
) : (
|
||||
<CompleteInputFile
|
||||
originalData={originalDataPath}
|
||||
fileName="path"
|
||||
modelRoute={modelName}
|
||||
// <CompleteInputFile
|
||||
// originalData={originalDataPath}
|
||||
// fileName="path"
|
||||
// modelRoute={modelName}
|
||||
// name="path"
|
||||
// label={str(transAssociatePathLabel)
|
||||
// .ucFirst()
|
||||
// .value()}
|
||||
// setData={setData}
|
||||
// hasRefreshBtn={false}
|
||||
// // @ts-expect-error Types diff between custom reset and useForm reset.
|
||||
// reset={reset}
|
||||
// value={data.path}
|
||||
// required={false}
|
||||
// maxFileSize="10240"
|
||||
// allowedFormats="pdf"
|
||||
// inputType={enumDynamicFieldTypes.file.value}
|
||||
// />
|
||||
<SimpleVideoUpload
|
||||
width={"100%"}
|
||||
height={100}
|
||||
label="Vidéo uploader"
|
||||
name="path"
|
||||
label={str(transAssociatePathLabel)
|
||||
.ucFirst()
|
||||
.value()}
|
||||
setData={setData}
|
||||
hasRefreshBtn={false}
|
||||
// @ts-expect-error Types diff between custom reset and useForm reset.
|
||||
reset={reset}
|
||||
value={data.path}
|
||||
required={false}
|
||||
maxFileSize="10240"
|
||||
allowedFormats="pdf"
|
||||
inputType={enumDynamicFieldTypes.file.value}
|
||||
required={true}
|
||||
|
||||
/>
|
||||
)}
|
||||
{(model?.issuu_path || model?.path) && (
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@
|
|||
@endif
|
||||
<div class="textblock__info textblock__block">
|
||||
<div class="textblock__info-description description">
|
||||
{!! clean($model['content']) !!}
|
||||
{!! $model['content'] !!}
|
||||
@if ($model['vimeo_id'])
|
||||
<x-front.sections.video :video="[
|
||||
'type_video' => 'vimeo',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue