Merge branch 'update-wysiwyg' into 'develop'

fix: 🐛 update wysiwyg @tiptap editor

See merge request ldc-web/projets/ldc!125
This commit is contained in:
karl.muller 2026-02-05 10:25:43 +00:00
commit cc65e4e63e
19 changed files with 1287 additions and 634 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,7 +13,7 @@ type SimpleSwitchProps = {
label?: string;
leftHelper?: string;
rightHelper?: string;
className?: string|null;
className?: string;
required?: boolean;
disabled?: boolean;
parentErrors?: Record<string, string> | null,
@ -27,7 +27,7 @@ export default function SimpleSwitch({
label,
leftHelper,
rightHelper,
className = null,
className = "",
required = false,
disabled = false,
parentErrors = null,

View file

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

View file

@ -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<string | File | null>(null);
// Value of the input alt image.
const [inputAlt, setInputAlt] = useState<string>("");
// 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<ImageType>(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<void>
*/
const uploadImage = async (file: File, alt: string): Promise<void> => {
const uploadImage = async (file: File): Promise<string | null> => {
// 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<void> => {
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<string, string> = {};
const keyMap: Record<string, string> = {
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<string, string> = {
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<string, string>;
};
};
};
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 (
<Dialog
open={open}
@ -247,8 +356,8 @@ export default function ImageButton({ editor, disabled }: ImageProps) {
size="icon"
/>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogContent className="p-4">
<DialogHeader className="px-2 pt-2">
<DialogTitle>
{useTrans("back.wysiwyg.image.title")}
</DialogTitle>
@ -256,36 +365,75 @@ export default function ImageButton({ editor, disabled }: ImageProps) {
{useTrans("back.wysiwyg.image.subtitle")}
</DialogDescription>
</DialogHeader>
<CompleteInputFile
fileName="image"
name="image"
label={str(useTrans("validation.custom.image")).ucFirst().value()}
setData={handleSetImage}
reset={(name) => console.log("reset", name)}
value={null}
required={true}
maxFileSize="5120"
allowedFormats="jpg,jpeg,png,webp"
validationRules={{
width: "1920",
height: "1080",
}}
parentErrors={errors}
/>
<SimpleString
name="alt"
label={str(useTrans("validation.custom.image_alt")).ucFirst().value()}
placeholder="ex: John Doe"
required={true}
value={inputAlt}
setData={handleSetAlt}
disabled={loading}
parentErrors={errors}
/>
<div className="flex justify-center items-center gap-1.5">
<div className="overflow-y-scroll max-h-[70vh] flex flex-col gap-4 px-2">
<div className="flex flex-col w-full justify-between items-start gap-1.5">
<Label htmlFor="image-type">
{str(useTrans("back.wysiwyg.image.format"))
.ucFirst()
.value()}
</Label>
<div className="flex flex-col items-center justify-center sm:flex-row w-full gap-4">
{Object.values(ImageTypes).map((type) => (
<Button
key={type.name}
onClick={() => setInputType(type)}
className="flex flex-col items-center gap-1.5 w-full h-fit"
variant={
inputType.name === type.name
? "default"
: "secondary"
}
>
<input
type="radio"
name="image-type"
value={type.name}
checked={inputType.name === type.name}
className="sr-only"
tabIndex={-1}
/>
<div className="w-16 h-16 bg-gradient-to-br rounded-lg flex items-center justify-center">
<div className={type.style} />
</div>
<p className="text-sm font-medium leading-none">
{useTrans(`back.wysiwyg.image.${type.name}`)}
</p>
</Button>
))}
</div>
</div>
<CompleteInputFile
key={inputType.name}
fileName="image"
name="image"
label={str(useTrans("back.wysiwyg.image.image"))
.ucFirst()
.value()}
setData={handleSetImage}
reset={(name) => 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}
/>
<ImageInputs
value={inputText}
onChange={setInputText}
disabled={disabled || loading}
errors={errors}
/>
</div>
<DialogFooter className="space-x-0 sm:space-x-0 gap-1.5 px-2 pb-2">
{/* Cancel button. */}
<BtnOptionnalLinkWithTooltip
content={str(useTrans("back.wysiwyg.image.cancel")).ucFirst().value()}
content={str(useTrans("back.wysiwyg.image.cancel"))
.ucFirst()
.value()}
tooltipContent={useTrans(
"back.wysiwyg.image.tooltip_cancel",
)}
@ -298,7 +446,9 @@ export default function ImageButton({ editor, disabled }: ImageProps) {
/>
{/* Save button. */}
<BtnOptionnalLinkWithTooltip
content={str(useTrans("back.wysiwyg.image.insert")).ucFirst().value()}
content={str(useTrans("back.wysiwyg.image.insert"))
.ucFirst()
.value()}
tooltipContent={useTrans(
"back.wysiwyg.image.tooltip_insert",
)}
@ -308,7 +458,7 @@ export default function ImageButton({ editor, disabled }: ImageProps) {
disabled={loading}
onClick={handleAddImage}
/>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);

View file

@ -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<string, string>;
loading?: boolean;
disabled?: boolean;
};
export default function ImageInputs({
value,
onChange,
errors = {},
loading = false,
disabled = false,
}: ImageInputsProps) {
return (
<>
{/* Input alt attribute of the image. */}
<SimpleString
name="alt"
label={str(useTrans("back.wysiwyg.image.alt"))
.ucFirst()
.value()}
placeholder="ex: John Doe"
required={true}
value={value.alt}
setData={(_, v) =>
onChange({ ...value, alt: v })
}
disabled={disabled || loading}
parentErrors={errors}
/>
<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.image.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 image. */}
{value.hasCaption && (
<SimpleString
name="caption"
label={str(useTrans("back.wysiwyg.image.caption")).ucFirst().value()}
placeholder="ex: John Doe"
required={false}
value={value.caption ?? ""}
setData={(_, v) =>
onChange({ ...value, caption: v })
}
disabled={disabled || loading}
parentErrors={errors}
/>
)}
</div>
</>
);
}

View file

@ -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<HTMLImageElement>(null);
const resizeState = useRef<{
startX: number;
@ -95,11 +99,43 @@ export default function ImageNodeView(props: NodeViewProps) {
const [isResizing, setIsResizing] = useState<boolean>(false);
const [open, setOpen] = useState<boolean>(false);
const [altValue, setAltValue] = useState<string>(attrs.alt);
const [captionValue, setCaptionValue] = useState<string | null>(
attrs.caption,
);
// Value of errors.
const [errors, setErrors] = useState<Record<string, string>>({});
// 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<number>(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<string, string> = {
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 (
<NodeViewWrapper
as={attrs.caption ? "figure" : "div"}
as="figure"
data-type="image"
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/90 backdrop-blur-sm rounded-md border shadow-md m-0 p-1">
{`${attrs.width || imageDetails.width}x${attrs.height || imageDetails.height}`}
</span>
</div>
)}
<img
ref={imgRef}
src={attrs.src}
@ -253,16 +356,17 @@ export default function ImageNodeView(props: NodeViewProps) {
draggable={false}
/>
{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">
<div className="absolute bottom-2 right-2 flex flex-col gap-1 bg-background/90 backdrop-blur-sm rounded-md border shadow-md p-1">
{/* Placement dropdown */}
<Select
value={getCurrentPlacement() || undefined}
value={getCurrentPlacement() || "default"}
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">
<SelectItem value="default" disabled>{useTrans("back.wysiwyg.image.position")}</SelectItem>
{PLACEMENT_OPTIONS.map((value) => {
const [type, dir] = value.split("-") as [
"align" | "float",
@ -290,7 +394,7 @@ export default function ImageNodeView(props: NodeViewProps) {
{value === "float-right" && (
<ArrowRightLeftIcon className="w-4 h-4" />
)}
{label}
{useTrans(`back.wysiwyg.image.${str(label).slug().value()}`)}
</div>
</SelectItem>
);
@ -308,7 +412,7 @@ export default function ImageNodeView(props: NodeViewProps) {
setOpen(true);
}}
>
<PencilIcon className="w-4 h-4" />
<ChevronsLeftRightEllipsisIcon className="w-4 h-4" />
</Button>
{/* Resize button */}
<Button
@ -326,11 +430,19 @@ export default function ImageNodeView(props: NodeViewProps) {
</div>
)}
{/* Caption */}
{attrs.caption && <figcaption>{attrs.caption}</figcaption>}
{attrs.caption && (
<figcaption
style={{
maxWidth: attrs.width || imageDetails.width,
}}
>
{attrs.caption}
</figcaption>
)}
{/* Dialog Shadcn */}
<Dialog open={open} onOpenChange={(status) => closeModal(status)}>
<DialogContent>
<DialogHeader>
<DialogContent className="p-4">
<DialogHeader className="px-2 pt-2">
<DialogTitle>
{useTrans("back.wysiwyg.image.edit_title")}
</DialogTitle>
@ -338,22 +450,15 @@ export default function ImageNodeView(props: NodeViewProps) {
{useTrans("back.wysiwyg.image.edit_subtitle")}
</DialogDescription>
</DialogHeader>
{/* Inputs */}
<SimpleString
name="image-alt"
label={useTrans("back.wysiwyg.image.alt")}
value={altValue}
setData={(_, value) => setAltValue(value)}
/>
<SimpleString
name="image-caption"
label={useTrans("back.wysiwyg.image.caption")}
value={captionValue ?? ""}
required={false}
setData={(_, value) => setCaptionValue(value)}
/>
<div className="overflow-y-scroll max-h-[70vh] flex flex-col gap-4 px-2">
<ImageInputs
value={inputText}
onChange={setInputText}
errors={errors}
/>
</div>
{/* Action buttons */}
<div className="flex justify-center items-center gap-1.5">
<DialogFooter className="space-x-0 sm:space-x-0 gap-1.5 px-2 pb-2">
<BtnOptionnalLinkWithTooltip
content={useTrans("back.wysiwyg.image.edit_cancel")}
tooltipContent={str(
@ -382,7 +487,7 @@ export default function ImageNodeView(props: NodeViewProps) {
variant="default"
size="default"
/>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</NodeViewWrapper>

View file

@ -0,0 +1,50 @@
// * Packages
import { z } from "zod";
// Set image details.
export const imageDetails = {
// Sizes (ratio of 1920x1080) in pixels.
width: "1008",
height: "567",
// Minimum dimension allowed for width and height in pixels.
minDim: "100",
maxDim: "1008",
// Weight in MB.
size: "5",
};
// Set image zod schema.
export const imageTextSchema = ({
hasCaption,
transValidationAlt,
transValidationCaption,
}: {
hasCaption: boolean;
transValidationAlt: {
min: string;
max: string;
string: string;
};
transValidationCaption: {
min: string;
max: string;
string: string;
};
}) => {
// Attribute alt zod rules.
const altRules = z
.string({ message: transValidationAlt.string })
.min(3, transValidationAlt.min)
.max(255, transValidationAlt.max);
// Caption zod rules.
const captionRules = z
.string({ message: transValidationAlt.string })
.min(3, transValidationCaption.min)
.max(255, transValidationCaption.max);
// Return zod object.
return z.object({
alt: altRules,
caption: hasCaption ? captionRules : z.null(),
});
};

View file

@ -1,11 +1,15 @@
import '@tiptap/core';
// Set custom function attributes.
declare module '@tiptap/core' {
interface Commands<ReturnType> {
Image: {
setImage: (attrs: {
src: string,
alt: string;
caption: string|null;
width: string;
height: string;
}) => ReturnType;
unsetImage: () => ReturnType;
};

View file

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

View file

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

View file

@ -2,28 +2,28 @@
import { Editor } from "@tiptap/react";
import { ElementType, useEffect, useState } from "react";
import {
AlignCenterIcon,
AlignJustifyIcon,
AlignLeftIcon,
AlignRightIcon,
BoldIcon,
DeleteIcon,
HeadingIcon,
ItalicIcon,
ListIcon,
ListOrderedIcon,
ListTodoIcon,
MinusIcon,
PilcrowIcon,
QuoteIcon,
RedoIcon,
RemoveFormattingIcon,
StrikethroughIcon,
SubscriptIcon,
SuperscriptIcon,
TextSelectIcon,
UnderlineIcon,
UndoIcon,
AlignCenterIcon,
AlignJustifyIcon,
AlignLeftIcon,
AlignRightIcon,
BoldIcon,
DeleteIcon,
HeadingIcon,
ItalicIcon,
ListIcon,
ListOrderedIcon,
ListTodoIcon,
MinusIcon,
PilcrowIcon,
QuoteIcon,
RedoIcon,
RemoveFormattingIcon,
StrikethroughIcon,
SubscriptIcon,
SuperscriptIcon,
TextSelectIcon,
UnderlineIcon,
UndoIcon,
} from "lucide-react";
// * Components shadcn
import { Separator } from "@/components/ui/separator";
@ -33,11 +33,11 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// * Other
import ImageButton from "@/back/Components/Inputs/Wysiwyg/Extensions/Image/ImageButton";
@ -54,370 +54,458 @@ type AllowedHeadings = 1 | 2 | 3 | 4 | 5 | 6;
type AllowedAlignements = "left" | "center" | "right" | "justify";
type ToolbarProps = {
editor: Editor;
editor: Editor;
};
export default function Toolbar({ editor }: ToolbarProps) {
const [, forceUpdate] = useState<number>(0);
const [docSelected, setDocSelected] = useState<boolean>(false);
const [, forceUpdate] = useState<number>(0);
const [docSelected, setDocSelected] = useState<boolean>(false);
const [headerHeight, setHeaderHeight] = useState<number>(0);
const headingLevels: AllowedHeadings[] = [1, 2, 3, 4, 5, 6];
const currentHeadingLevel = headingLevels.find(level =>
editor.isActive("heading", { level })
) ?? "";
const headingLevels: AllowedHeadings[] = [1, 2, 3, 4, 5, 6];
const currentHeadingLevel =
headingLevels.find((level) => editor.isActive("heading", { level })) ??
"";
const textAligns: { icon: ElementType, value: AllowedAlignements }[] = [
{
icon: AlignLeftIcon,
value: "left",
},
{
icon: AlignCenterIcon,
value: "center",
},
{
icon: AlignRightIcon,
value: "right",
},
{
icon: AlignJustifyIcon,
value: "justify",
},
];
const currentTextAlign = textAligns.find(align => editor.isActive({ textAlign: align })) ?? "left";
const textAligns: { icon: ElementType; value: AllowedAlignements }[] = [
{
icon: AlignLeftIcon,
value: "left",
},
{
icon: AlignCenterIcon,
value: "center",
},
{
icon: AlignRightIcon,
value: "right",
},
{
icon: AlignJustifyIcon,
value: "justify",
},
];
const currentTextAlign =
textAligns.find((align) => editor.isActive({ textAlign: align })) ??
textAligns[0];
/**
* Set heading to the selected content.
*
* @param value string
* @return void
*/
const handleHeadingChange = (value: string): void => {
editor.chain().focus();
/**
* Set heading to the selected content.
*
* @param value string
* @return void
*/
const handleHeadingChange = (value: string): void => {
editor.chain().focus();
if (value === "paragraph") {
editor.chain().focus().setParagraph().run();
return;
}
const level = Number(value) as AllowedHeadings;
editor.chain().focus().toggleHeading({ level }).run();
}
// Force re-render editor on select to update activated extensions.
useEffect(() => {
if (!editor) return;
const update = () => forceUpdate(value => value + 1);
if (value === "paragraph") {
editor.chain().focus().setParagraph().run();
return;
}
editor.on("selectionUpdate", () => {
update();
setDocSelected(editor.state.selection.$anchor.parent.type.name === "doc")
});
return () => {
editor.off("selectionUpdate", update);
const level = Number(value) as AllowedHeadings;
editor.chain().focus().toggleHeading({ level }).run();
};
}, [editor]);
return (
<div className="sticky top-[72px] flex flex-wrap justify-start items-stretch bg-background gap-1 border-b rounded-t-md p-1.5 z-10">
<BtnOptionnalLinkWithTooltip
content={<UndoIcon className="w-4 h-4 m-auto" />}
tooltipContent={str(useTrans("back.wysiwyg.undo")).ucFirst().value()}
onClick={(event) => {
event.preventDefault();
editor.chain().focus().undo().run();
}}
disabled={!editor.can().chain().focus().undo().run()}
type="button"
variant="ghost"
size="icon"
/>
<BtnOptionnalLinkWithTooltip
content={<RedoIcon className="w-4 h-4 m-auto" />}
tooltipContent={str(useTrans("back.wysiwyg.redo")).ucFirst().value()}
onClick={(event) => {
event.preventDefault();
editor.chain().focus().redo().run();
}}
disabled={!editor.can().chain().focus().redo().run()}
type="button"
variant="ghost"
size="icon"
/>
<Separator orientation="vertical" className="!h-auto" />
<BtnOptionnalLinkWithTooltip
content={<BoldIcon className="w-4 h-4 m-auto" />}
tooltipContent={str(useTrans("back.wysiwyg.bold")).ucFirst().value()}
onClick={(event) => {
event.preventDefault();
editor.chain().focus().toggleBold().run();
}}
className={editor.isActive("bold") ? "bg-accent" : ""}
disabled={docSelected}
type="button"
variant="ghost"
size="icon"
/>
<BtnOptionnalLinkWithTooltip
content={<ItalicIcon className="w-4 h-4 m-auto" />}
tooltipContent={str(useTrans("back.wysiwyg.italic")).ucFirst().value()}
onClick={(event) => {
event.preventDefault();
editor.chain().focus().toggleItalic().run();
}}
className={editor.isActive("italic") ? "bg-accent" : ""}
disabled={docSelected}
type="button"
variant="ghost"
size="icon"
/>
<BtnOptionnalLinkWithTooltip
content={<StrikethroughIcon className="w-4 h-4 m-auto" />}
tooltipContent={str(useTrans("back.wysiwyg.strike")).ucFirst().value()}
onClick={(event) => {
event.preventDefault();
editor.chain().focus().toggleStrike().run();
}}
className={editor.isActive("strike") ? "bg-accent" : ""}
disabled={docSelected}
type="button"
variant="ghost"
size="icon"
/>
<BtnOptionnalLinkWithTooltip
content={<UnderlineIcon className="w-4 h-4 m-auto" />}
tooltipContent={str(useTrans("back.wysiwyg.underline")).ucFirst().value()}
onClick={(event) => {
event.preventDefault();
editor.chain().focus().toggleUnderline().run();
}}
className={editor.isActive("underline") ? "bg-accent" : ""}
disabled={docSelected}
type="button"
variant="ghost"
size="icon"
/>
<BtnOptionnalLinkWithTooltip
content={<QuoteIcon className="w-4 h-4 m-auto" />}
tooltipContent={str(useTrans("back.wysiwyg.blockquote")).ucFirst().value()}
onClick={(event) => {
event.preventDefault();
editor.chain().focus().toggleBlockquote().run();
}}
className={editor.isActive("blockquote") ? "bg-accent" : ""}
disabled={docSelected}
type="button"
variant="ghost"
size="icon"
/>
<Separator orientation="vertical" className="!h-auto" />
<Select
value={currentHeadingLevel.toString()}
onValueChange={(value) => handleHeadingChange(value)}
disabled={docSelected}
>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger className={cn(
"w-auto border-none transition-colors",
{ "hover:bg-accent hover:text-accent-foreground" : !docSelected },
{ "!cursor-default": docSelected },
)}>
<HeadingIcon className="w-4 h-4" />
{currentHeadingLevel && (
<SelectValue>{currentHeadingLevel?.toString()}</SelectValue>
)}
</SelectTrigger>
</TooltipTrigger>
<TooltipContent>{useTrans("back.wysiwyg.heading")}</TooltipContent>
</Tooltip>
<SelectContent>
{/* Define default paragraph value to reset heading */}
<SelectItem value="paragraph">
{useTrans("back.wysiwyg.heading_paragraph")}
</SelectItem>
{/* List of headings */}
{headingLevels.map(level => (
<SelectItem key={level} value={level.toString()}>
{useTrans("back.wysiwyg.heading_x", { params: { hx: String(level) } })}
</SelectItem>
))}
</SelectContent>
</Select>
<Separator orientation="vertical" className="!h-auto" />
<BtnOptionnalLinkWithTooltip
content={<ListIcon className="w-4 h-4 m-auto" />}
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"
/>
<BtnOptionnalLinkWithTooltip
content={<ListOrderedIcon className="w-4 h-4 m-auto" />}
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"
/>
<BtnOptionnalLinkWithTooltip
content={<ListTodoIcon className="w-4 h-4 m-auto" />}
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"
/>
<Separator orientation="vertical" className="!h-auto" />
<Select
value={currentTextAlign.toString()}
onValueChange={(value) => {
const align = value as AllowedAlignements;
editor.chain().focus().setTextAlign(align).run();
}}
disabled={docSelected}
>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger className={cn(
"w-auto border-none transition-colors",
{ "hover:bg-accent hover:text-accent-foreground" : !docSelected },
{ "!cursor-default": docSelected },
)}>
<AlignLeftIcon className="w-4 h-4" />
</SelectTrigger>
</TooltipTrigger>
<TooltipContent>{useTrans("back.wysiwyg.align")}</TooltipContent>
</Tooltip>
<SelectContent>
{textAligns.map(align => (
<SelectItem
key={align.value}
value={align.value.toString()}
// Force re-render editor on select to update activated extensions.
useEffect(() => {
if (!editor) return;
const update = () => forceUpdate((value) => value + 1);
editor.on("selectionUpdate", () => {
update();
setDocSelected(
editor.state.selection.$anchor.parent.type.name === "doc",
);
});
return () => {
editor.off("selectionUpdate", update);
};
}, [editor]);
// Set header height with resize event.
useEffect(() => {
const handleResize = () => {
setHeaderHeight(document.querySelector("header")?.clientHeight ?? 0);
};
window.addEventListener('resize', handleResize);
handleResize();
return () => window.removeEventListener('resize', handleResize);
}, []);
return (
<div
className="tiptap-toolbar sticky flex flex-wrap justify-start items-stretch bg-background gap-1 border-b rounded-t-md p-1.5 z-10"
style={{
top: headerHeight,
}}
>
<BtnOptionnalLinkWithTooltip
content={<UndoIcon className="w-4 h-4 m-auto" />}
tooltipContent={str(useTrans("back.wysiwyg.undo"))
.ucFirst()
.value()}
onClick={(event) => {
event.preventDefault();
editor.chain().focus().undo().run();
}}
disabled={!editor.can().chain().focus().undo().run()}
type="button"
variant="ghost"
size="icon"
/>
<BtnOptionnalLinkWithTooltip
content={<RedoIcon className="w-4 h-4 m-auto" />}
tooltipContent={str(useTrans("back.wysiwyg.redo"))
.ucFirst()
.value()}
onClick={(event) => {
event.preventDefault();
editor.chain().focus().redo().run();
}}
disabled={!editor.can().chain().focus().redo().run()}
type="button"
variant="ghost"
size="icon"
/>
<Separator orientation="vertical" className="!h-auto" />
<BtnOptionnalLinkWithTooltip
content={<BoldIcon className="w-4 h-4 m-auto" />}
tooltipContent={str(useTrans("back.wysiwyg.bold"))
.ucFirst()
.value()}
onClick={(event) => {
event.preventDefault();
editor.chain().focus().toggleBold().run();
}}
className={editor.isActive("bold") ? "bg-accent" : ""}
disabled={docSelected}
type="button"
variant="ghost"
size="icon"
/>
<BtnOptionnalLinkWithTooltip
content={<ItalicIcon className="w-4 h-4 m-auto" />}
tooltipContent={str(useTrans("back.wysiwyg.italic"))
.ucFirst()
.value()}
onClick={(event) => {
event.preventDefault();
editor.chain().focus().toggleItalic().run();
}}
className={editor.isActive("italic") ? "bg-accent" : ""}
disabled={docSelected}
type="button"
variant="ghost"
size="icon"
/>
<BtnOptionnalLinkWithTooltip
content={<StrikethroughIcon className="w-4 h-4 m-auto" />}
tooltipContent={str(useTrans("back.wysiwyg.strike"))
.ucFirst()
.value()}
onClick={(event) => {
event.preventDefault();
editor.chain().focus().toggleStrike().run();
}}
className={editor.isActive("strike") ? "bg-accent" : ""}
disabled={docSelected}
type="button"
variant="ghost"
size="icon"
/>
<BtnOptionnalLinkWithTooltip
content={<UnderlineIcon className="w-4 h-4 m-auto" />}
tooltipContent={str(useTrans("back.wysiwyg.underline"))
.ucFirst()
.value()}
onClick={(event) => {
event.preventDefault();
editor.chain().focus().toggleUnderline().run();
}}
className={editor.isActive("underline") ? "bg-accent" : ""}
disabled={docSelected}
type="button"
variant="ghost"
size="icon"
/>
<BtnOptionnalLinkWithTooltip
content={<QuoteIcon className="w-4 h-4 m-auto" />}
tooltipContent={str(useTrans("back.wysiwyg.blockquote"))
.ucFirst()
.value()}
onClick={(event) => {
event.preventDefault();
editor.chain().focus().toggleBlockquote().run();
}}
className={editor.isActive("blockquote") ? "bg-accent" : ""}
disabled={docSelected}
type="button"
variant="ghost"
size="icon"
/>
<Separator orientation="vertical" className="!h-auto" />
<Select
value={currentHeadingLevel.toString()}
onValueChange={(value) => handleHeadingChange(value)}
disabled={docSelected}
>
<div className="flex flex-row jsutify-center items-center gap-1.5">
{<align.icon className="w-4 h-4" />}
{useTrans(`back.wysiwyg.align_${align.value}`)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Separator orientation="vertical" className="!h-auto" />
<BtnOptionnalLinkWithTooltip
content={<SubscriptIcon className="w-4 h-4 m-auto" />}
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"
/>
<BtnOptionnalLinkWithTooltip
content={<SuperscriptIcon className="w-4 h-4 m-auto" />}
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"
/>
<Separator orientation="vertical" className="!h-auto" />
<BtnOptionnalLinkWithTooltip
content={<MinusIcon className="w-4 h-4 m-auto" />}
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"
/>
<TableButton editor={editor} disabled={docSelected} />
<ImageButton editor={editor} disabled={docSelected} />
<LinkButton editor={editor} disabled={docSelected} />
<Separator orientation="vertical" className="!h-auto" />
<BtnOptionnalLinkWithTooltip
content={<PilcrowIcon className="w-4 h-4 m-auto" />}
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"
/>
<FindReplaceButton editor={editor} />
<BtnOptionnalLinkWithTooltip
content={<TextSelectIcon className="w-4 h-4 m-auto" />}
tooltipContent={str(useTrans("back.wysiwyg.select_all")).ucFirst().value()}
onClick={(event) => {
event.preventDefault();
editor.chain().focus().selectAll().run();
}}
type="button"
variant="ghost"
size="icon"
/>
<BtnOptionnalLinkWithTooltip
content={<RemoveFormattingIcon className="w-4 h-4 m-auto" />}
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"
/>
<BtnOptionnalLinkWithTooltip
content={<DeleteIcon className="w-4 h-4 m-auto" />}
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"
/>
</div>
);
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger
className={cn(
"w-auto border-none transition-colors",
{
"hover:bg-accent hover:text-accent-foreground":
!docSelected,
},
{ "!cursor-default": docSelected },
)}
>
<HeadingIcon className="w-4 h-4" />
{currentHeadingLevel && (
<SelectValue>
{currentHeadingLevel?.toString()}
</SelectValue>
)}
</SelectTrigger>
</TooltipTrigger>
<TooltipContent>
{useTrans("back.wysiwyg.heading")}
</TooltipContent>
</Tooltip>
<SelectContent>
{/* Define default paragraph value to reset heading */}
<SelectItem value="paragraph">
{useTrans("back.wysiwyg.heading_paragraph")}
</SelectItem>
{/* List of headings */}
{headingLevels.map((level) => (
<SelectItem key={level} value={level.toString()}>
{useTrans("back.wysiwyg.heading_x", {
params: { hx: String(level) },
})}
</SelectItem>
))}
</SelectContent>
</Select>
<Separator orientation="vertical" className="!h-auto" />
<BtnOptionnalLinkWithTooltip
content={<ListIcon className="w-4 h-4 m-auto" />}
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"
/>
<BtnOptionnalLinkWithTooltip
content={<ListOrderedIcon className="w-4 h-4 m-auto" />}
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"
/>
<BtnOptionnalLinkWithTooltip
content={<ListTodoIcon className="w-4 h-4 m-auto" />}
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"
/>
<Separator orientation="vertical" className="!h-auto" />
<Select
value={currentTextAlign.value.toString()}
onValueChange={(value) => {
const align = value as AllowedAlignements;
editor.chain().focus().setTextAlign(align).run();
}}
disabled={docSelected}
>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger
className={cn(
"w-auto border-none transition-colors",
{
"hover:bg-accent hover:text-accent-foreground":
!docSelected,
},
{ "!cursor-default": docSelected },
)}
>
<AlignLeftIcon className="w-4 h-4" />
</SelectTrigger>
</TooltipTrigger>
<TooltipContent>
{useTrans("back.wysiwyg.align")}
</TooltipContent>
</Tooltip>
<SelectContent>
{textAligns.map((align) => (
<SelectItem
key={align.value}
value={align.value.toString()}
>
<div className="flex flex-row jsutify-center items-center gap-1.5">
{<align.icon className="w-4 h-4" />}
{useTrans(`back.wysiwyg.align_${align.value}`)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Separator orientation="vertical" className="!h-auto" />
<BtnOptionnalLinkWithTooltip
content={<SubscriptIcon className="w-4 h-4 m-auto" />}
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"
/>
<BtnOptionnalLinkWithTooltip
content={<SuperscriptIcon className="w-4 h-4 m-auto" />}
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"
/>
<Separator orientation="vertical" className="!h-auto" />
<BtnOptionnalLinkWithTooltip
content={<MinusIcon className="w-4 h-4 m-auto" />}
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"
/>
<TableButton editor={editor} disabled={docSelected} />
<ImageButton editor={editor} disabled={docSelected} />
<LinkButton editor={editor} disabled={docSelected} />
<Separator orientation="vertical" className="!h-auto" />
<BtnOptionnalLinkWithTooltip
content={<PilcrowIcon className="w-4 h-4 m-auto" />}
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"
/>
<FindReplaceButton editor={editor} />
<BtnOptionnalLinkWithTooltip
content={<TextSelectIcon className="w-4 h-4 m-auto" />}
tooltipContent={str(useTrans("back.wysiwyg.select_all"))
.ucFirst()
.value()}
onClick={(event) => {
event.preventDefault();
editor.chain().focus().selectAll().run();
}}
type="button"
variant="ghost"
size="icon"
/>
<BtnOptionnalLinkWithTooltip
content={<RemoveFormattingIcon className="w-4 h-4 m-auto" />}
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"
/>
<BtnOptionnalLinkWithTooltip
content={<DeleteIcon className="w-4 h-4 m-auto" />}
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"
/>
</div>
);
}

View file

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

View file

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