Merge branch 'update-wysiwyg' into 'develop'
fix: 🐛 update wysiwyg @tiptap editor
See merge request ldc-web/projets/ldc!125
This commit is contained in:
commit
cc65e4e63e
19 changed files with 1287 additions and 634 deletions
|
|
@ -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'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]] : []),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
28
resources/ts/hooks/use-inline-style.tsx
Normal file
28
resources/ts/hooks/use-inline-style.tsx
Normal 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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue