feat: ✨ add video iframe plugin

This commit is contained in:
K Muller 2026-02-16 17:08:47 +01:00
parent dac9afe2e6
commit 85db25c72c
14 changed files with 1362 additions and 16 deletions

View 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',
];
}

View file

@ -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",

View file

@ -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",

View 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>
);
}

View file

@ -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();
},
};
},
});

View file

@ -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>
);
}

View file

@ -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}
/>
</>
);
}

View file

@ -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>
);
}

View file

@ -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,
});
};

View 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;
}

View file

@ -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
[],

View file

@ -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" />}

View file

@ -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) && (

View file

@ -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',