From e6f0ca3cde47626b9c55871d4e008b37366dbc74 Mon Sep 17 00:00:00 2001 From: Alexis Gousseau Date: Thu, 5 Feb 2026 11:10:03 +0100 Subject: [PATCH 1/4] fix: :children_crossing: add a shortcut limit to prevent events from propagating in the wysiwyg editor --- .../Extensions/ShortCuts/ShortcutBoundary.tsx | 24 +++++++++++++++++++ .../Inputs/Wysiwyg/SimpleWysiwyg.tsx | 2 ++ 2 files changed, 26 insertions(+) create mode 100644 resources/ts/back/Components/Inputs/Wysiwyg/Extensions/ShortCuts/ShortcutBoundary.tsx diff --git a/resources/ts/back/Components/Inputs/Wysiwyg/Extensions/ShortCuts/ShortcutBoundary.tsx b/resources/ts/back/Components/Inputs/Wysiwyg/Extensions/ShortCuts/ShortcutBoundary.tsx new file mode 100644 index 00000000..b737a342 --- /dev/null +++ b/resources/ts/back/Components/Inputs/Wysiwyg/Extensions/ShortCuts/ShortcutBoundary.tsx @@ -0,0 +1,24 @@ +// * Packages +import { Extension } from "@tiptap/core"; +import { Plugin } from "prosemirror-state"; + +export const ShortcutBoundary = Extension.create({ + addProseMirrorPlugins() { + return [ + new Plugin({ + props: { + // Set new event on key down. + handleKeyDown(view, event) { + if (!(event.ctrlKey || event.metaKey)) return false; + + // Stop the propagation event to the app. + event.stopPropagation(); + + // Keep @tiptap editor manage the shortcut. + return false; + }, + }, + }), + ]; + }, +}); diff --git a/resources/ts/back/Components/Inputs/Wysiwyg/SimpleWysiwyg.tsx b/resources/ts/back/Components/Inputs/Wysiwyg/SimpleWysiwyg.tsx index c389e0d6..d1b84570 100644 --- a/resources/ts/back/Components/Inputs/Wysiwyg/SimpleWysiwyg.tsx +++ b/resources/ts/back/Components/Inputs/Wysiwyg/SimpleWysiwyg.tsx @@ -17,6 +17,7 @@ import TextAlign from "@tiptap/extension-text-align"; // * Components shadcn import { Label } from "@/components/ui/label"; // * Other +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 InputError from "@/back/Components/InputError"; @@ -92,6 +93,7 @@ export default function SimpleWysiwyg({ link: false, // Custom link extension. }), // Specific extensions. + ShortcutBoundary, TaskList, TaskItem.configure({ nested: true, From 5c3d05dd7cef3124a069548c98540ab0ff8a82f1 Mon Sep 17 00:00:00 2001 From: Alexis Gousseau Date: Thu, 5 Feb 2026 11:12:10 +0100 Subject: [PATCH 2/4] fix: :lipstick: add style for missing html tags in wysiwyg editor --- public/assets/css/media.css | 4 -- public/assets/css/style.css | 28 +++++--------- resources/sass/common/_wysiwyg.scss | 58 +++++++++++++++++++++++------ 3 files changed, 55 insertions(+), 35 deletions(-) diff --git a/public/assets/css/media.css b/public/assets/css/media.css index d74394b2..ef90b07b 100644 --- a/public/assets/css/media.css +++ b/public/assets/css/media.css @@ -1133,10 +1133,6 @@ .manifest__btn:nth-child(2) { bottom: 125px; } - .manifest__quote { - font-size: 30px; - margin-bottom: 15px; - } .engagements-page-ecosystem-block .textblock__image { margin: 5px auto 0px; } diff --git a/public/assets/css/style.css b/public/assets/css/style.css index cdff07d6..9840ede4 100644 --- a/public/assets/css/style.css +++ b/public/assets/css/style.css @@ -459,6 +459,15 @@ iframe { text-align: center; } +.blocks-gallery-grid, +.wp-block-columns, +.wp-block-media-text { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; +} + .blocks-gallery-item::before { content: none !important; } @@ -946,15 +955,6 @@ textarea:focus { flex: 1; } -.quote__text { - color: #222222; - width: 90%; - font-family: "Satisfy"; - font-size: 2vw; - line-height: 1.4; - margin-bottom: 20px; -} - .quote__author { font-weight: 700; font-size: 16px; @@ -1014,16 +1014,6 @@ textarea:focus { } } -.quote__text::before, -.quote__text::after { - content: '"'; - color: #ff4e4e; -} - -.quote__text p { - display: inline; -} - .quote__description-toggle { box-sizing: border-box; display: flex; diff --git a/resources/sass/common/_wysiwyg.scss b/resources/sass/common/_wysiwyg.scss index b9427fcc..3cc9fd1f 100644 --- a/resources/sass/common/_wysiwyg.scss +++ b/resources/sass/common/_wysiwyg.scss @@ -14,6 +14,12 @@ $tertiary-color: #222222; font-size: 14px; } +.description h1 { + font-size: 35px; + line-height: 1.2; + margin-bottom: 0.9em; +} + .description h2 { font-size: 28px; line-height: 1.2; @@ -64,17 +70,20 @@ $tertiary-color: #222222; margin: 25px 0; } -.description ul { +.description ul, +.description ol { padding: 20px 0 10px; } -.blocks-gallery-grid, -.wp-block-columns, -.wp-block-media-text { - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; +.description ol { + list-style: decimal; + padding-left: 0.75rem; +} + +.description ol li::marker { + font-size: 12px; + margin-right: 12px; + margin-top: 6px; } .description ul li { @@ -103,14 +112,39 @@ $tertiary-color: #222222; margin-top: 6px; } -.description img { - display: inline-block; -} - .description figure figcaption { text-align: center; } +.description blockquote, +.manifest__quote { + font-size: 30px; + margin-bottom: 15px; +} + +.description blockquote, +.quote__text { + color: #222222; + width: 90%; + font-family: "Satisfy"; + font-size: 2vw; + line-height: 1.4; + margin-bottom: 20px; +} + +.description blockquote::before, +.description blockquote::after, +.quote__text::before, +.quote__text::after { + content: '"'; + color: #ff4e4e; +} + +.description blockquote p, +.quote__text p { + display: inline; +} + @media screen and (max-width: 1240px) { .description { font-size: 14px; From 0d972a0c7b64bbf4af849349f9ad3a718f153f1c Mon Sep 17 00:00:00 2001 From: Alexis Gousseau Date: Thu, 5 Feb 2026 11:17:31 +0100 Subject: [PATCH 3/4] fix: :bug: update uwil image extension of wysiwyg editor (formats, translations, errors, render html, style) --- app/Http/Requests/Back/UploadFileRequest.php | 2 +- .../Api/HasLaravelFailedValidation.php | 9 +- lang/en/back.php | 12 + lang/fr/back.php | 14 +- .../back/Components/Inputs/SimpleSwitch.tsx | 4 +- .../Inputs/Wysiwyg/Extensions/Image/Image.tsx | 65 +- .../Wysiwyg/Extensions/Image/ImageButton.tsx | 370 +++++--- .../Wysiwyg/Extensions/Image/ImageInputs.tsx | 78 ++ .../Extensions/Image/ImageNodeView.tsx | 227 +++-- .../Wysiwyg/Extensions/Image/ImageUtils.tsx | 50 + .../Wysiwyg/Extensions/Image/image.d.ts | 4 + .../Components/Inputs/Wysiwyg/Toolbar.tsx | 852 ++++++++++-------- resources/ts/hooks/use-inline-style.tsx | 28 + 13 files changed, 1116 insertions(+), 599 deletions(-) create mode 100644 resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageInputs.tsx create mode 100644 resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageUtils.tsx create mode 100644 resources/ts/hooks/use-inline-style.tsx diff --git a/app/Http/Requests/Back/UploadFileRequest.php b/app/Http/Requests/Back/UploadFileRequest.php index cc4c7cdd..13e95443 100644 --- a/app/Http/Requests/Back/UploadFileRequest.php +++ b/app/Http/Requests/Back/UploadFileRequest.php @@ -27,7 +27,7 @@ class UploadFileRequest extends FormRequest public function rules(): array { return [ - 'upload' => 'required|image|mimes:jpeg,png,jpg|max:5048' + 'upload' => 'required|image|mimes:jpeg,png,jpg|max:5048|dimensions:min_width=567,min_height=567,max_width:1008,max_height:1008' ]; } } diff --git a/app/Traits/Requests/Api/HasLaravelFailedValidation.php b/app/Traits/Requests/Api/HasLaravelFailedValidation.php index 3a6115b9..7f834473 100644 --- a/app/Traits/Requests/Api/HasLaravelFailedValidation.php +++ b/app/Traits/Requests/Api/HasLaravelFailedValidation.php @@ -2,15 +2,22 @@ namespace App\Traits\Requests\Api; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Http\Exceptions\HttpResponseException; trait HasLaravelFailedValidation { - protected function failedValidation(\Illuminate\Contracts\Validation\Validator $validator) + /** + * Set manually a failed ajax response. + * + * @return \Illuminate\Http\Exceptions\HttpResponseException + */ + protected function failedValidation(Validator $validator): \Illuminate\Http\Exceptions\HttpResponseException { $response = response()->json([ 'errors' => $validator->errors(), ], 422); + throw new HttpResponseException($response); } } diff --git a/lang/en/back.php b/lang/en/back.php index 4a5f80f9..4357dfd6 100644 --- a/lang/en/back.php +++ b/lang/en/back.php @@ -254,8 +254,20 @@ return [ "edit_tooltip_apply" => "Update the image attributes", "edit_cancel" => "Cancel", "edit_tooltip_cancel" => "Cancel updating the image attributes", + "position" => "Image position", + "format" => "Image format", + "image" => "Image", "alt" => "ALT attribute of the image", "caption" => "Caption of the image", + "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", ], "table" => [ "tooltip" => "Table", diff --git a/lang/fr/back.php b/lang/fr/back.php index 9c11aa6d..a3da4f4e 100644 --- a/lang/fr/back.php +++ b/lang/fr/back.php @@ -255,8 +255,20 @@ return [ "edit_tooltip_apply" => "Mettre à jour les attributs de l'image", "edit_cancel" => "Annuler", "edit_tooltip_cancel" => "Annuler la mise à jour des attributs de l'image", - "alt" => "Attribut ALT de l'image", + "position" => "Position de l'image", + "format" => "Format de l'image", + "image" => "Image", + "alt" => "Texte alternatif de l'image", "caption" => "Légende de l'image", + "has_caption" => "Présence d'une légende", + "landscape" => "Paysage", + "portrait" => "Portrait", + "square" => "Carré", + "align-left" => "Aligner à gauche", + "align-center" => "Aligner au centre", + "align-right" => "Aligner à droite", + "wrap-left" => "Flottant à gauche", + "wrap-right" => "Flottant à droite", ], "table" => [ "tooltip" => "Tableau", diff --git a/resources/ts/back/Components/Inputs/SimpleSwitch.tsx b/resources/ts/back/Components/Inputs/SimpleSwitch.tsx index 4bd41a06..e5bc012d 100644 --- a/resources/ts/back/Components/Inputs/SimpleSwitch.tsx +++ b/resources/ts/back/Components/Inputs/SimpleSwitch.tsx @@ -13,7 +13,7 @@ type SimpleSwitchProps = { label?: string; leftHelper?: string; rightHelper?: string; - className?: string|null; + className?: string; required?: boolean; disabled?: boolean; parentErrors?: Record | null, @@ -27,7 +27,7 @@ export default function SimpleSwitch({ label, leftHelper, rightHelper, - className = null, + className = "", required = false, disabled = false, parentErrors = null, diff --git a/resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Image/Image.tsx b/resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Image/Image.tsx index 766b27fa..f81a4b1b 100644 --- a/resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Image/Image.tsx +++ b/resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Image/Image.tsx @@ -3,11 +3,10 @@ import { Image as TiptapImage } from "@tiptap/extension-image"; import { ReactNodeViewRenderer } from "@tiptap/react"; // * Other import ImageNodeView, { - ALIGN_STYLES, - AllowedAligns, FLOAT_STYLES, FloatOption, } from "@/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageNodeView"; +import { imageDetails } from "@/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageUtils"; export const Image = TiptapImage.extend({ // Define attributes we need. @@ -24,38 +23,25 @@ export const Image = TiptapImage.extend({ }, // Rules for each tag. // Set all attributes if matching on simple image or figure structure. + // Use for render html in @tiptap editor. parseHTML() { return [ { - // Specific selector for a simple image. - tag: "img[src]", - getAttrs: (element: HTMLElement) => { - const img = element as HTMLImageElement; - - return { - src: img.src, - alt: img.getAttribute("alt"), - width: img.width, - height: img.height, - style: null, - caption: null, - }; - }, - }, - { - // Specific selector for a figure with his image. + // Specific selector for a figure with his image and an optional figcaption. tag: "figure:has(img[src])", getAttrs: (element: HTMLElement) => { - const img = element.querySelector( - "img", - ) as HTMLImageElement; + const img = element.querySelector("img") as HTMLImageElement; const figcaption = element.querySelector("figcaption"); return { src: img.src, alt: img.getAttribute("alt"), - width: img.width, - height: img.height, + width: parseInt( + img.getAttribute("width") || imageDetails.width, + ), + height: parseInt( + img.getAttribute("height") || imageDetails.height, + ), style: null, caption: figcaption?.textContent, }; @@ -63,34 +49,31 @@ export const Image = TiptapImage.extend({ }, ]; }, + // Use a custom node view in the editor. + addNodeView() { + return ReactNodeViewRenderer(ImageNodeView); + }, // Define the structure of the render. + // Use for render html in front files. renderHTML({ node, HTMLAttributes }) { delete HTMLAttributes.caption; delete HTMLAttributes.float; const style = [ - HTMLAttributes.style, + HTMLAttributes.style ?? "margin: 0px;", node.attrs.float ? FLOAT_STYLES[node.attrs.float as FloatOption] - : ALIGN_STYLES[node.attrs.align as AllowedAligns] || "", + : null, ] .filter(Boolean) .join(" "); - if (node.attrs.caption) { - delete HTMLAttributes.style; - return [ - "figure", - { style }, - ["img", HTMLAttributes], - ["figcaption", node.attrs.caption], - ]; - } - - return ["img", { ...HTMLAttributes, style }]; - }, - // Use a custom node view in the editor. - addNodeView() { - return ReactNodeViewRenderer(ImageNodeView); + delete HTMLAttributes.style; + return [ + "figure", + { style }, + ["img", HTMLAttributes], + ...(node.attrs.caption ? [["figcaption", node.attrs.caption]] : []), + ]; }, }); diff --git a/resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageButton.tsx b/resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageButton.tsx index 8814a4f3..10ca781b 100644 --- a/resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageButton.tsx +++ b/resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageButton.tsx @@ -1,5 +1,5 @@ // * Packages -import { useState } from "react"; +import { useEffect, useState } from "react"; import axios from "axios"; import { route } from "ziggy-js"; import { ImageUpIcon } from "lucide-react"; @@ -13,11 +13,16 @@ import { DialogHeader, DialogTitle, DialogTrigger, + DialogFooter, } from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; // * Other +import ImageInputs from "@/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageInputs"; +import { imageTextSchema } from "@/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageUtils"; +import { imageDetails } from "@/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageUtils"; import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip"; import CompleteInputFile from "@/back/Components/Inputs/CompleteInputFile"; -import SimpleString from "@/back/Components/Inputs/SimpleString"; import useTrans from "@/hooks/use-translations"; import str from "@/hooks/use-string"; @@ -28,16 +33,34 @@ type ImageProps = { disabled: boolean; }; +type ImageFormat = "landscape" | "portrait" | "square"; +type ImageType = { + name: ImageFormat; + style: string; + width: number; + height: number; +}; + export default function ImageButton({ editor, disabled }: ImageProps) { // Get all editor options define with customOptions in the parent SimpleWysiwyg. - const editorOptions = editor.options.editorProps?.attributes as { [name: string]: string }; + const editorOptions = editor.options.editorProps?.attributes as { + [name: string]: string; + }; // State of the plugin modal. const [open, setOpen] = useState(false); // Value of the input file image. const [inputFile, setFile] = useState(null); - // Value of the input alt image. - const [inputAlt, setInputAlt] = useState(""); + // Values of inputs text. + const [inputText, setInputText] = useState<{ + alt: string; + caption: string | null; + hasCaption: boolean; + }>({ + alt: "", + caption: null, + hasCaption: false, + }); // State of the loader. const [loading, setLoading] = useState(false); @@ -47,16 +70,58 @@ export default function ImageButton({ editor, disabled }: ImageProps) { // Duration of modal closing animation. const animationTime = 200; - const transTooltip = str(useTrans("back.wysiwyg.image.tooltip")).ucFirst().value(); + // Translations. + const transTooltip = str(useTrans("back.wysiwyg.image.tooltip")) + .ucFirst() + .value(); + const transImage = useTrans("back.wysiwyg.image.image"); + const transAlt = useTrans("back.wysiwyg.image.alt"); + const transCaption = useTrans("back.wysiwyg.image.caption"); + const transValidation = (attribute: string) => { + return { + min: useTrans("validation.min.string", { + params: { attribute, min: "3" }, + }), + max: useTrans("validation.max.string", { + params: { attribute, max: "255" }, + }), + string: useTrans("validation.string", { params: { attribute } }), + }; + }; + const transValidationAlt = transValidation(transAlt); + const transValidationCaption = transValidation(transCaption); + + // List of image types. + const ImageTypes: ImageType[] = [ + { + name: "landscape", + style: "w-full h-8 bg-blue-400 rounded-md", + width: Number(imageDetails.width), + height: Number(imageDetails.height), + }, + { + name: "portrait", + style: "w-8 h-full bg-green-400 rounded-md", + width: Number(imageDetails.height), + height: Number(imageDetails.width), + }, + { + name: "square", + style: "w-12 h-12 bg-purple-400 rounded-md", + width: Number(imageDetails.width), + height: Number(imageDetails.width), + }, + ]; + + // Value of the input type image. + const [inputType, setInputType] = useState(ImageTypes[0]); // Zod image schema. - const inputsSchema = z.object({ + const inputFileSchema = z.object({ inputFile: z .instanceof(File, { message: useTrans("validation.file", { - params: { - attribute: useTrans("validation.custom.image"), - }, + params: { attribute: transImage }, }), }) .refine( @@ -69,54 +134,31 @@ export default function ImageButton({ editor, disabled }: ImageProps) { ].includes(file.type), { message: useTrans("validation.regex", { - params: { - attribute: useTrans("validation.custom.image"), - }, + params: { attribute: transImage }, }), }, ) - .refine((file) => file.size <= 5 * 1024 * 1024, { - message: useTrans("validation.size.file", { - params: { - attribute: useTrans("validation.custom.image"), - size: "5 000", - }, - }), - }), - inputAlt: z - .string({ - message: useTrans("validation.string", { - params: { - attribute: useTrans("validation.custom.image_alt"), - }, - }), - }) - .min(3, { - message: useTrans("validation.min.string", { - params: { - attribute: useTrans("validation.custom.image_alt"), - min: "3", - }, - }), - }) - .max(255, { - message: useTrans("validation.max.string", { - params: { - attribute: useTrans("validation.custom.image_alt"), - max: "255", - }, - }), - }), + .refine( + (file) => + file.size <= parseInt(imageDetails.size) * 1024 * 1024, + { + message: useTrans("validation.size.file", { + params: { + attribute: transImage, + size: imageDetails.size + " 000", + }, + }), + }, + ), }); /** * Upload image in storage. - * - * @param file - * @param alt + * + * @param file * @return Promise */ - const uploadImage = async (file: File, alt: string): Promise => { + const uploadImage = async (file: File): Promise => { // Get the csrf token value. const csrfToken = document @@ -128,6 +170,7 @@ export default function ImageButton({ editor, disabled }: ImageProps) { data.append("upload", file); data.append("folder", editorOptions.model); + // Get wysiwyg image route upload. const uploadRoute = route("back.wysiwyg.upload"); try { @@ -140,12 +183,10 @@ export default function ImageButton({ editor, disabled }: ImageProps) { "X-CSRF-TOKEN": csrfToken, }, }); - editor.commands.setImage({ - src: response.data.url, - alt: alt, - }); - } catch (error: Error | unknown) { - throw new Error(`Erreur upload: ${(error as Error).message}`); + return response.data.url; + } catch (error: unknown) { + setNewErrors(error); + return null; } }; @@ -154,43 +195,97 @@ export default function ImageButton({ editor, disabled }: ImageProps) { * * @return void */ - const handleAddImage = (): void => { + const handleAddImage = async (): Promise => { setLoading(true); + setErrors({}); + + // Get zod schema for input texts. + const inputTextSchema = imageTextSchema({ + hasCaption: inputText.hasCaption, + transValidationAlt, + transValidationCaption, + }); try { - const parsed = inputsSchema.parse({ inputFile, inputAlt }); - uploadImage(parsed.inputFile, parsed.inputAlt); - setOpen(false); - setTimeout(() => resetComponent(), animationTime); - } catch (err) { - // Get zod error validations. - if (err instanceof z.ZodError) { - const fieldErrors: Record = {}; - const keyMap: Record = { - inputFile: "image", - inputAlt: "alt", - }; - - err.issues.forEach((e) => { - const mappedKey = keyMap[e.path[0] as string]; - if (mappedKey) fieldErrors[mappedKey] = e.message; + // Merge texts and file zod schemas and validate data. + const parsedZod = z + .intersection(inputFileSchema, inputTextSchema) + .parse({ + inputFile, + alt: inputText.alt, + caption: inputText.caption, }); - setErrors(fieldErrors); + // Upload image. + const imageSrc = await uploadImage(parsedZod.inputFile); + if (imageSrc) { + // Insert image in @tiptap editor. + editor.commands.setImage({ + src: imageSrc, + alt: parsedZod.alt, + caption: parsedZod.caption, + width: String(inputType.width), + height: String(inputType.height), + }); + setOpen(false); + setTimeout(() => resetComponent(), animationTime); } + } catch (error: unknown) { + setNewErrors(error); } finally { setLoading(false); } }; /** - * Set new alt attribute. + * Set new errors from object errors * + * @param err unknown * @return void */ - const handleSetAlt = (name: string, value: string) => { - setErrors((prev) => ({ ...prev, [name]: "" })); - setInputAlt(value); + const setNewErrors = (err: unknown): void => { + const fieldErrors: typeof errors = {}; + const fieldMapping: Record = { + upload: "image", + inputFile: "image", + alt: "alt", + caption: "caption", + }; + // 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; + } + // Manage laravel errors. + if (err && typeof err === "object" && "response" in err) { + let currentErrors = err as { + response: { + data: { + errors: Record; + }; + }; + }; + + Object.entries(currentErrors.response.data.errors).forEach( + ([laravelField, messages]) => { + const componentField = fieldMapping[laravelField]; + if (componentField && messages.length > 0) { + fieldErrors[componentField] = messages[0]; + } + }, + ); + + setErrors(fieldErrors); + return; + } }; /** @@ -200,7 +295,12 @@ export default function ImageButton({ editor, disabled }: ImageProps) { */ const resetComponent = () => { setFile(null); - setInputAlt(""); + setInputText({ + alt: "", + caption: null, + hasCaption: false, + }); + setInputType(ImageTypes[0]) setErrors({}); setLoading(false); }; @@ -228,6 +328,15 @@ export default function ImageButton({ editor, disabled }: ImageProps) { setTimeout(() => resetComponent(), animationTime); }; + // Set default value to the caption. + useEffect(() => { + setInputText({ + alt: inputText.alt, + caption: inputText.hasCaption ? inputText.caption : null, + hasCaption: inputText.hasCaption, + }); + }, [inputText.hasCaption]); + return ( - - + + {useTrans("back.wysiwyg.image.title")} @@ -256,36 +365,75 @@ export default function ImageButton({ editor, disabled }: ImageProps) { {useTrans("back.wysiwyg.image.subtitle")} - console.log("reset", name)} - value={null} - required={true} - maxFileSize="5120" - allowedFormats="jpg,jpeg,png,webp" - validationRules={{ - width: "1920", - height: "1080", - }} - parentErrors={errors} - /> - -
+
+
+ +
+ {Object.values(ImageTypes).map((type) => ( + + ))} +
+
+ console.log("reset", name)} + value={null} + required={true} + maxFileSize="5120" + allowedFormats="jpg,jpeg,png,webp" + validationRules={{ + width: String(inputType.width), + height: String(inputType.height), + }} + parentErrors={errors} + /> + +
+ {/* Cancel button. */} {/* Save button. */} -
+
); diff --git a/resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageInputs.tsx b/resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageInputs.tsx new file mode 100644 index 00000000..d79d0d17 --- /dev/null +++ b/resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageInputs.tsx @@ -0,0 +1,78 @@ +// * 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"; + +type ImageInputsProps = { + value: { + alt: string; + caption: string | null; + hasCaption: boolean; + }; + onChange: (value: ImageInputsProps["value"]) => void; + errors?: Record; + loading?: boolean; + disabled?: boolean; +}; + +export default function ImageInputs({ + value, + onChange, + errors = {}, + loading = false, + disabled = false, + }: ImageInputsProps) { + return ( + <> + {/* Input alt attribute of the image. */} + + onChange({ ...value, alt: v }) + } + disabled={disabled || loading} + parentErrors={errors} + /> +
+ {/* Input switch to set the caption visibility. */} + + onChange({ + ...value, + hasCaption: v, + caption: v ? value.caption : null, + }) + } + /> + {/* Input caption of the image. */} + {value.hasCaption && ( + + onChange({ ...value, caption: v }) + } + disabled={disabled || loading} + parentErrors={errors} + /> + )} +
+ + ); +} diff --git a/resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageNodeView.tsx b/resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageNodeView.tsx index 434f6aa2..19efe267 100644 --- a/resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageNodeView.tsx +++ b/resources/ts/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageNodeView.tsx @@ -2,15 +2,16 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { MoveDiagonal2Icon, - PencilIcon, AlignLeftIcon, AlignCenterIcon, AlignRightIcon, ArrowLeftRightIcon, ArrowRightLeftIcon, LayoutIcon, + ChevronsLeftRightEllipsisIcon, } from "lucide-react"; -import { CSSProperties, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import z from "zod"; // * Components shadcn import { Button } from "@/components/ui/button"; import { @@ -19,6 +20,7 @@ import { DialogHeader, DialogTitle, DialogDescription, + DialogFooter, } from "@/components/ui/dialog"; import { Select, @@ -27,11 +29,14 @@ import { SelectTrigger, } from "@/components/ui/select"; // * Other +import ImageInputs from "@/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageInputs"; +import { imageDetails } from "@/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageUtils"; +import { imageTextSchema } from "@/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageUtils"; import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip"; -import SimpleString from "@/back/Components/Inputs/SimpleString"; import useTrans from "@/hooks/use-translations"; import str from "@/hooks/use-string"; import { cn } from "@/lib/utils"; +import { parseStyleString } from "@/hooks/use-inline-style"; type ImageAttrs = { src: string; @@ -85,7 +90,6 @@ export default function ImageNodeView(props: NodeViewProps) { const { node, updateAttributes, selected } = props; const attrs = node.attrs as ImageAttrs; - const minSizeImgAllowed = 100; const imgRef = useRef(null); const resizeState = useRef<{ startX: number; @@ -95,11 +99,43 @@ export default function ImageNodeView(props: NodeViewProps) { const [isResizing, setIsResizing] = useState(false); const [open, setOpen] = useState(false); - const [altValue, setAltValue] = useState(attrs.alt); - const [captionValue, setCaptionValue] = useState( - attrs.caption, - ); + // Value of errors. + const [errors, setErrors] = useState>({}); + // Values of inputs text. + const [inputText, setInputText] = useState<{ + alt: string; + caption: string | null; + hasCaption: boolean; + }>({ + alt: attrs.alt, + caption: attrs.caption, + hasCaption: Boolean(attrs.caption) ? true : false, + }); + // Value of the height of header + @tiptap toolbar editor. + const [topSticky, setTopSticky] = useState(0); + // Translations. + const transAlt = useTrans("back.wysiwyg.image.alt"); + const transCaption = useTrans("back.wysiwyg.image.caption"); + const transValidation = (attribute: string) => { + return { + min: useTrans("validation.min.string", { + params: { attribute, min: "3" }, + }), + max: useTrans("validation.max.string", { + params: { attribute, max: "255" }, + }), + string: useTrans("validation.string", { params: { attribute } }), + }; + }; + const transValidationAlt = transValidation(transAlt); + const transValidationCaption = transValidation(transCaption); + + /** + * Get the current placement of the image (align or wrap or null). + * + * @return PlacementValue | null + */ const getCurrentPlacement = (): PlacementValue | null => { if (attrs.float === "left") return "float-left"; if (attrs.float === "right") return "float-right"; @@ -110,6 +146,11 @@ export default function ImageNodeView(props: NodeViewProps) { return null; }; + /** + * Update attribute when changing placement. + * + * @param PlacementValue value + */ const handlePlacementChange = (value: PlacementValue) => { updateAttributes({ style: PLACEMENT_STYLES[value], @@ -153,14 +194,22 @@ export default function ImageNodeView(props: NodeViewProps) { let newWidth = startWidth + (event.clientX - startX); let newHeight = newWidth / ratio; - if (newHeight < minSizeImgAllowed) { - newHeight = minSizeImgAllowed; + if (newWidth < Number(imageDetails.minDim)) { + newWidth = Number(imageDetails.minDim); + newHeight = newWidth / ratio; + } + if (newHeight < Number(imageDetails.minDim)) { + newHeight = Number(imageDetails.minDim); newWidth = newHeight * ratio; } - if (newWidth < minSizeImgAllowed) { - newWidth = minSizeImgAllowed; + if (newWidth > Number(imageDetails.maxDim)) { + newWidth = Number(imageDetails.maxDim); newHeight = newWidth / ratio; } + if (newHeight > Number(imageDetails.maxDim)) { + newHeight = Number(imageDetails.maxDim); + newWidth = newHeight * ratio; + } updateAttributes({ width: Math.round(newWidth), @@ -184,12 +233,55 @@ export default function ImageNodeView(props: NodeViewProps) { * @return void */ const saveDialog = (): void => { - updateAttributes({ - alt: altValue, - caption: captionValue, + const inputTextSchema = imageTextSchema({ + hasCaption: inputText.hasCaption, + transValidationAlt, + transValidationCaption, }); - setOpen(false); + + try { + // Validate data. + const parsedZod = inputTextSchema + .parse({ + alt: inputText.alt, + caption: inputText.caption, + }); + + // Update data of the image in @tiptap editor. + updateAttributes({ + alt: parsedZod.alt, + caption: parsedZod.caption, + }); + setOpen(false); + } catch (error: unknown) { + setNewErrors(error); + } }; + /** + * Set new errors from object errors + * + * @param err unknown + * @return void + */ + const setNewErrors = (err: unknown): void => { + const fieldErrors: typeof errors = {}; + const fieldMapping: Record = { + alt: "alt", + caption: "caption", + }; + 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; + } + }; /** * Close modal and reset component. @@ -201,27 +293,6 @@ export default function ImageNodeView(props: NodeViewProps) { setOpen(status); }; - // Convertit une string CSS en objet React style - const parseStyleString = ( - styleString: string | null | undefined, - ): CSSProperties => { - if (!styleString) return {}; - - const style: CSSProperties = {}; - styleString.split(";").forEach((rule) => { - const [property, value] = rule.split(":").map((s) => s.trim()); - if (property && value) { - // Conversion kebab-case → camelCase - const camelProperty = property.replace(/-([a-z])/g, (g) => - g[1].toUpperCase(), - ); - style[camelProperty as keyof CSSProperties] = value as any; - } - }); - - return style; - }; - // Reset events when resizing. useEffect(() => { if (!isResizing) return; @@ -235,15 +306,47 @@ export default function ImageNodeView(props: NodeViewProps) { }; }, [isResizing]); + // Set default value to the caption. + useEffect(() => { + setInputText({ + alt: inputText.alt, + caption: inputText.hasCaption ? inputText.caption : null, + hasCaption: inputText.hasCaption, + }); + }, [inputText.hasCaption]); + + // Set height of header + @tiptap toolbar editor with resize event. + 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); + }, []); + return ( + {selected && ( +
+ + {`${attrs.width || imageDetails.width}x${attrs.height || imageDetails.height}`} + +
+ )} {selected && ( -
+
{/* Placement dropdown */} handleHeadingChange(value)} - disabled={docSelected} - > - - - - - {currentHeadingLevel && ( - {currentHeadingLevel?.toString()} - )} - - - {useTrans("back.wysiwyg.heading")} - - - {/* Define default paragraph value to reset heading */} - - {useTrans("back.wysiwyg.heading_paragraph")} - - {/* List of headings */} - {headingLevels.map(level => ( - - {useTrans("back.wysiwyg.heading_x", { params: { hx: String(level) } })} - - ))} - - - - } - tooltipContent={str(useTrans("back.wysiwyg.bullet_list")).ucFirst().value()} - onClick={(event) => { - event.preventDefault(); - editor.chain().focus().toggleBulletList().run(); - }} - className={editor.isActive("bulletList") ? "bg-accent" : ""} - disabled={docSelected} - type="button" - variant="ghost" - size="icon" - /> - } - tooltipContent={str(useTrans("back.wysiwyg.ordered_list")).ucFirst().value()} - onClick={(event) => { - event.preventDefault(); - editor.chain().focus().toggleOrderedList().run(); - }} - className={editor.isActive("orderedList") ? "bg-accent" : ""} - disabled={docSelected} - type="button" - variant="ghost" - size="icon" - /> - } - tooltipContent={str(useTrans("back.wysiwyg.task_list")).ucFirst().value()} - onClick={(event) => { - event.preventDefault(); - editor.chain().focus().toggleTaskList().run(); - }} - className={editor.isActive("taskList") ? "bg-accent" : ""} - disabled={docSelected} - type="button" - variant="ghost" - size="icon" - /> - - handleHeadingChange(value)} + disabled={docSelected} > -
- {} - {useTrans(`back.wysiwyg.align_${align.value}`)} -
- - ))} - - - - } - tooltipContent={str(useTrans("back.wysiwyg.subscript")).ucFirst().value()} - onClick={(event) => { - event.preventDefault(); - editor.chain().focus().toggleSubscript().run(); - }} - className={editor.isActive("subscript") ? "bg-accent" : ""} - disabled={docSelected} - type="button" - variant="ghost" - size="icon" - /> - } - tooltipContent={str(useTrans("back.wysiwyg.superscript")).ucFirst().value()} - onClick={(event) => { - event.preventDefault(); - editor.chain().focus().toggleSuperscript().run(); - }} - className={editor.isActive("superscript") ? "bg-accent" : ""} - disabled={docSelected} - type="button" - variant="ghost" - size="icon" - /> - - } - tooltipContent={str(useTrans("back.wysiwyg.horizontal_rule")).ucFirst().value()} - onClick={(event) => { - event.preventDefault(); - editor.chain().focus().setHorizontalRule().run(); - }} - className={editor.isActive("horizontalRule") ? "bg-accent" : ""} - type="button" - variant="ghost" - size="icon" - /> - - - - - } - tooltipContent={str(useTrans("back.wysiwyg.invisible_characters")).ucFirst().value()} - onClick={(event) => { - event.preventDefault(); - editor.commands.toggleInvisibleCharacters(); - }} - className={editor.storage.invisibleCharacters?.visibility() ? "bg-accent" : ""} - type="button" - variant="ghost" - size="icon" - /> - - } - tooltipContent={str(useTrans("back.wysiwyg.select_all")).ucFirst().value()} - onClick={(event) => { - event.preventDefault(); - editor.chain().focus().selectAll().run(); - }} - type="button" - variant="ghost" - size="icon" - /> - } - tooltipContent={str(useTrans("back.wysiwyg.clear_selection")).ucFirst().value()} - onClick={(event) => { - event.preventDefault(); - editor.chain().focus() - .unsetAllMarks() - .clearNodes() - .setParagraph() - .setTextAlign("left") - .run(); - }} - disabled={editor.getText().trim() === "" && editor.state.selection.empty} - type="button" - variant="ghost" - size="icon" - /> - } - tooltipContent={str(useTrans("back.wysiwyg.delete_selection")).ucFirst().value()} - onClick={(event) => { - event.preventDefault(); - editor.chain().focus().deleteSelection().run(); - }} - disabled={editor.getText().trim() === "" && editor.state.selection.empty} - type="button" - variant="ghost" - size="icon" - /> -
- ); + + + + + {currentHeadingLevel && ( + + {currentHeadingLevel?.toString()} + + )} + + + + {useTrans("back.wysiwyg.heading")} + + + + {/* Define default paragraph value to reset heading */} + + {useTrans("back.wysiwyg.heading_paragraph")} + + {/* List of headings */} + {headingLevels.map((level) => ( + + {useTrans("back.wysiwyg.heading_x", { + params: { hx: String(level) }, + })} + + ))} + + + + } + tooltipContent={str(useTrans("back.wysiwyg.bullet_list")) + .ucFirst() + .value()} + onClick={(event) => { + event.preventDefault(); + editor.chain().focus().toggleBulletList().run(); + }} + className={editor.isActive("bulletList") ? "bg-accent" : ""} + disabled={docSelected} + type="button" + variant="ghost" + size="icon" + /> + } + tooltipContent={str(useTrans("back.wysiwyg.ordered_list")) + .ucFirst() + .value()} + onClick={(event) => { + event.preventDefault(); + editor.chain().focus().toggleOrderedList().run(); + }} + className={editor.isActive("orderedList") ? "bg-accent" : ""} + disabled={docSelected} + type="button" + variant="ghost" + size="icon" + /> + } + tooltipContent={str(useTrans("back.wysiwyg.task_list")) + .ucFirst() + .value()} + onClick={(event) => { + event.preventDefault(); + editor.chain().focus().toggleTaskList().run(); + }} + className={editor.isActive("taskList") ? "bg-accent" : ""} + disabled={docSelected} + type="button" + variant="ghost" + size="icon" + /> + + + + } + tooltipContent={str(useTrans("back.wysiwyg.subscript")) + .ucFirst() + .value()} + onClick={(event) => { + event.preventDefault(); + editor.chain().focus().toggleSubscript().run(); + }} + className={editor.isActive("subscript") ? "bg-accent" : ""} + disabled={docSelected} + type="button" + variant="ghost" + size="icon" + /> + } + tooltipContent={str(useTrans("back.wysiwyg.superscript")) + .ucFirst() + .value()} + onClick={(event) => { + event.preventDefault(); + editor.chain().focus().toggleSuperscript().run(); + }} + className={editor.isActive("superscript") ? "bg-accent" : ""} + disabled={docSelected} + type="button" + variant="ghost" + size="icon" + /> + + } + tooltipContent={str(useTrans("back.wysiwyg.horizontal_rule")) + .ucFirst() + .value()} + onClick={(event) => { + event.preventDefault(); + editor.chain().focus().setHorizontalRule().run(); + }} + className={editor.isActive("horizontalRule") ? "bg-accent" : ""} + type="button" + variant="ghost" + size="icon" + /> + + + + + } + tooltipContent={str( + useTrans("back.wysiwyg.invisible_characters"), + ) + .ucFirst() + .value()} + onClick={(event) => { + event.preventDefault(); + editor.commands.toggleInvisibleCharacters(); + }} + className={ + editor.storage.invisibleCharacters?.visibility() + ? "bg-accent" + : "" + } + type="button" + variant="ghost" + size="icon" + /> + + } + tooltipContent={str(useTrans("back.wysiwyg.select_all")) + .ucFirst() + .value()} + onClick={(event) => { + event.preventDefault(); + editor.chain().focus().selectAll().run(); + }} + type="button" + variant="ghost" + size="icon" + /> + } + tooltipContent={str(useTrans("back.wysiwyg.clear_selection")) + .ucFirst() + .value()} + onClick={(event) => { + event.preventDefault(); + editor + .chain() + .focus() + .unsetAllMarks() + .clearNodes() + .setParagraph() + .setTextAlign("left") + .run(); + }} + disabled={ + editor.getText().trim() === "" && + editor.state.selection.empty + } + type="button" + variant="ghost" + size="icon" + /> + } + tooltipContent={str(useTrans("back.wysiwyg.delete_selection")) + .ucFirst() + .value()} + onClick={(event) => { + event.preventDefault(); + editor.chain().focus().deleteSelection().run(); + }} + disabled={ + editor.getText().trim() === "" && + editor.state.selection.empty + } + type="button" + variant="ghost" + size="icon" + /> +
+ ); } diff --git a/resources/ts/hooks/use-inline-style.tsx b/resources/ts/hooks/use-inline-style.tsx new file mode 100644 index 00000000..d92d21df --- /dev/null +++ b/resources/ts/hooks/use-inline-style.tsx @@ -0,0 +1,28 @@ +// * Packages +import { CSSProperties } from "react"; + +/** + * Convert string CSS into react style. + * + * @param styleString string|null|undefined + * @return CSSProperties + */ +export const parseStyleString = ( + styleString: string | null | undefined, +): CSSProperties => { + if (!styleString) return {}; + + const style: CSSProperties = {}; + styleString.split(";").forEach((rule) => { + const [property, value] = rule.split(":").map((s) => s.trim()); + if (property && value) { + // Conversion kebab-case → camelCase + const camelProperty = property.replace(/-([a-z])/g, (g) => + g[1].toUpperCase(), + ); + style[camelProperty as keyof CSSProperties] = value as any; + } + }); + + return style; +}; \ No newline at end of file From 398be941b0156039e660450b783ae8e7d964f8ea Mon Sep 17 00:00:00 2001 From: Alexis Gousseau Date: Thu, 5 Feb 2026 11:19:08 +0100 Subject: [PATCH 4/4] fix: :bulb: add comments in use-string hook with some examples --- resources/ts/hooks/use-string.tsx | 90 +++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/resources/ts/hooks/use-string.tsx b/resources/ts/hooks/use-string.tsx index 778449aa..811b47df 100644 --- a/resources/ts/hooks/use-string.tsx +++ b/resources/ts/hooks/use-string.tsx @@ -2,20 +2,110 @@ import slugify from "slugify"; import pluralize, { singular } from "pluralize"; interface Stringable { + /** + * Trim a word based. + * Examples: + * - "...Some.$.in.the.GARDEN !..." -> Some $ in the GARDEN ! + * + * @return Stringable + */ trim: () => Stringable; + /** + * Slugify a word based. + * Examples: + * - Some $ in the GARDEN ! -> some-dollar-in-the-garden + * + * @return Stringable + */ slug: () => Stringable; + /** + * Pascal case a word based. + * Examples: + * - some $ in the GARDEN ! -> Some $ in the GARDEN ! + * + * @return Stringable + */ ucFirst: () => Stringable; + /** + * Pascal case a word based. + * Examples: + * - Some $ in the GARDEN ! -> some$InTheGarden! + * + * @return Stringable + */ camelCase: () => Stringable; + /** + * Pascal case a word based. + * Examples: + * - some $ in the GARDEN ! -> Some$InTheGarden! + * + * @return Stringable + */ pascalCase: () => Stringable; + /** + * Kebab case a word based. + * Examples: + * - Some $ in the GARDEN ! -> some-$-in-the-garden-! + * + * @return Stringable + */ kebabCase: () => Stringable; + /** + * Snake case a word based. + * Examples: + * - Some $ in the GARDEN ! -> some_$_in_the_garden_! + * + * @return Stringable + */ snakeCase: () => Stringable; + /** + * Upper case a word based. + * Examples: + * - Some $ in the GARDEN ! -> SOME $ IN THE GARDEN ! + * + * @return Stringable + */ upperCase: () => Stringable; + /** + * Lower case a word based. + * Examples: + * - Some $ in the GARDEN ! -> some $ in the garden ! + * + * @return Stringable + */ lowerCase: () => Stringable; + /** + * Pluralize a word based. + * Examples: + * - word -> words + * - story -> stories + * + * @return Stringable + */ plural: () => Stringable; + /** + * Singularize a word based. + * Examples: + * - words -> word + * - stories -> story + * + * @return Stringable + */ singular: () => Stringable; + /** + * Return the transformated string. + * + * @return string + */ value: () => string; } +/** + * Get a Stringable object. + * + * @param string initialValue + * @return Stringable + */ const str = (initialValue: string): Stringable => { let value = initialValue;