|
import style from "./style.module.scss"; |
|
import {Preview} from "@/components/preview"; |
|
import cn from "classnames"; |
|
import {useState, useRef, MutableRef, useEffect} from "preact/hooks"; |
|
import {JSX} from "preact"; |
|
import { |
|
User, |
|
Type, |
|
Bold, |
|
Italic, |
|
Underline, |
|
ChevronRight, |
|
EyeOff, |
|
Smile, |
|
Image, |
|
} from "preact-feather"; |
|
import {smileysMap} from "@/utils/smileys"; |
|
import {Button} from "@/components/button"; |
|
import {Input} from "@/components/input"; |
|
import {Alert} from "@/components/alert"; |
|
|
|
const iconSize = 14; |
|
|
|
|
|
|
|
|
|
|
|
|
|
export function Wysiwyg(props: { |
|
showTitle: boolean, |
|
onSubmit: (user: string, title: string, text: string) => void, |
|
disabled?: boolean, |
|
initialText?: string, |
|
text?: string, |
|
setText?: (text: string) => void, |
|
}) { |
|
const [errors, setErrors] = useState<string[]>([]); |
|
const [title, setTitle] = useState(""); |
|
const [user, setUser] = useState(""); |
|
const [text, setText] = props.text !== undefined && props.setText !== undefined ? [props.text, props.setText] : useState(props.initialText || ""); |
|
const [accordion, setAccordion] = useState<"smileys"|"stickers"|false>(false); |
|
|
|
|
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null); |
|
const selectionRange = useRef<[start: number, end: number]|null>(null); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
if (textareaRef.current && selectionRange.current) { |
|
const [start, end] = selectionRange.current; |
|
textareaRef.current.setSelectionRange(start, end); |
|
selectionRange.current = null; |
|
} |
|
}, [text]) |
|
|
|
const bold = createAction(textareaRef, (start, end) => { |
|
const tag = "'''"; |
|
|
|
setText(insertAt(insertAt(text, tag, start), tag, end + tag.length)); |
|
selectionRange.current = [start + tag.length, end + tag.length]; |
|
}); |
|
|
|
const italic = createAction(textareaRef, (start, end) => { |
|
const tag = "''"; |
|
|
|
setText(insertAt(insertAt(text, tag, start), tag, end + tag.length)); |
|
selectionRange.current = [start + tag.length, end + tag.length]; |
|
}); |
|
|
|
const underline = createAction(textareaRef, (start, end) => { |
|
const startTag = "<u>"; |
|
const endTag = "</u>"; |
|
|
|
setText(insertAt(insertAt(text, startTag, start), endTag, end + startTag.length)); |
|
selectionRange.current = [start + startTag.length, end + startTag.length]; |
|
}); |
|
|
|
const quote = createAction(textareaRef, (start, end) => { |
|
const lines = text.split("\n"); |
|
const newLines: string[] = []; |
|
let charCount = 0; |
|
let quoteEnd = 0; |
|
let addedChars = 0; |
|
for (const line of lines) { |
|
charCount += line.length + 1; |
|
if (charCount > start && (charCount - line.length) <= end + 1) { |
|
newLines.push(`> ${line}`); |
|
addedChars += 2; |
|
quoteEnd = charCount; |
|
} else { |
|
newLines.push(line); |
|
} |
|
} |
|
|
|
setText(newLines.join("\n")); |
|
selectionRange.current = [quoteEnd + addedChars - 1, quoteEnd + addedChars - 1]; |
|
}); |
|
|
|
const spoil = createAction(textareaRef, (start, end) => { |
|
const startTag = "<spoil>"; |
|
const endTag = "</spoil>"; |
|
|
|
setText(insertAt(insertAt(text, startTag, start), endTag, end + startTag.length)); |
|
selectionRange.current = [start + startTag.length, end + startTag.length]; |
|
}); |
|
|
|
return ( |
|
<div> |
|
<Alert lines={errors}/> |
|
<Input |
|
type="text" |
|
icon={User} |
|
value={user} |
|
onChange={v => setUser(v as string)} |
|
placeholder="Pseudo" |
|
/> |
|
{ |
|
props.showTitle ? ( |
|
<Input |
|
type="text" |
|
icon={Type} |
|
value={title} |
|
onChange={v => setTitle(v as string)} |
|
placeholder="Titre du sujet" |
|
/> |
|
) : null |
|
} |
|
<div className={style.container}> |
|
<div className={style.toolbar}> |
|
<div className={style.toolbarGroup}> |
|
<div className={style.buttonContainer} title="Gras" onMouseDown={bold}> |
|
<Bold size={iconSize}/> |
|
</div> |
|
<div className={style.buttonContainer} title="Italique" onMouseDown={italic}> |
|
<Italic size={iconSize}/> |
|
</div> |
|
<div className={style.buttonContainer} title="Souligné" onMouseDown={underline}> |
|
<Underline size={iconSize}/> |
|
</div> |
|
</div> |
|
<div className={style.toolbarGroup}> |
|
<div className={style.buttonContainer} title="Citation" onMouseDown={quote}> |
|
<ChevronRight size={iconSize}/> |
|
</div> |
|
<div className={style.buttonContainer} title="Spoil" onMouseDown={spoil}> |
|
<EyeOff size={iconSize}/> |
|
</div> |
|
</div> |
|
<div className={style.toolbarGroup}> |
|
<div |
|
className={style.buttonContainer} |
|
title="Smileys" |
|
onClick={() => { |
|
setAccordion(accordion ? false : "smileys") |
|
}} |
|
> |
|
<Smile size={iconSize}/> |
|
</div> |
|
<div className={style.buttonContainer} title="Stickers"> |
|
<Image size={iconSize}/> |
|
</div> |
|
</div> |
|
</div> |
|
<div className={cn(style.accordion, {[style.accordionOpened]: accordion})}> |
|
<SmileyList |
|
text={text} |
|
setText={setText} |
|
textareaRef={textareaRef} |
|
selectionRange={selectionRange} |
|
/> |
|
</div> |
|
<div className={style.editor}> |
|
<textarea |
|
className={style.textarea} |
|
placeholder="Saisissez un message…" |
|
name="wysiwyg" |
|
id="wysiwyg" |
|
cols="30" |
|
rows="10" |
|
value={text} |
|
onInput={(e) => setText((e.target as HTMLTextAreaElement).value)} |
|
ref={textareaRef} // Attach ref to textarea |
|
/> |
|
</div> |
|
</div> |
|
<div className={cn(style.container, style.previewContainer)}> |
|
<Preview raw={text}/> |
|
</div> |
|
<Button |
|
secondary={true} |
|
onClick={() => { |
|
const errors: string[] = []; |
|
|
|
if(user.length < 2) { |
|
errors.push("Le pseudo doit se composer au minimum de 3 caractères"); |
|
} |
|
|
|
if(user.match(/[^a-zA-Z0-9_-]/)) { |
|
errors.push("Le pseudo doit uniquement contenir les caractères suivant: a-z, A-Z, 0-9, _ et -"); |
|
} |
|
|
|
if(props.showTitle && title.length < 2) { |
|
errors.push("Le titre du sujet doit se composer au minimum de 3 caractères"); |
|
} |
|
|
|
if(text.length < 2) { |
|
errors.push("Le text du sujet doit se composer au minimum de 3 caractères"); |
|
} |
|
|
|
setErrors(errors); |
|
|
|
if(errors.length > 0) { |
|
return; |
|
} |
|
|
|
props.onSubmit(user, title, text); |
|
setUser(""); |
|
setTitle(""); |
|
setText(""); |
|
}} |
|
disabled={props.disabled} |
|
> |
|
Poster |
|
</Button> |
|
</div> |
|
); |
|
} |
|
|
|
function SmileyList(props: { |
|
text: string, |
|
setText: (text: string) => void, |
|
textareaRef: MutableRef<HTMLTextAreaElement | null>, |
|
selectionRange: MutableRef<[start: number, end: number]|null>, |
|
}) { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const addSmiley: (smiley: string) => JSX.MouseEventHandler<HTMLDivElement> = (smiley) => (e) => { |
|
e.preventDefault(); |
|
if (props.textareaRef.current) { |
|
props.setText(insertAt(props.text, smiley, props.textareaRef.current.selectionStart)); |
|
props.selectionRange.current = [props.textareaRef.current.selectionStart + smiley.length, props.textareaRef.current.selectionStart + smiley.length]; |
|
props.textareaRef.current.focus(); |
|
} |
|
} |
|
|
|
return ( |
|
<div className={style.smileyList}> |
|
{smileysMap.map((smiley) => ( |
|
<div onMouseDown={addSmiley(` ${smiley[0]}`)}> |
|
<img |
|
src={`https://image.jeuxvideo.com/smileys_img/${smiley[1]}.gif`} |
|
alt={smiley[0]} |
|
/> |
|
</div> |
|
))} |
|
</div> |
|
) |
|
} |
|
|
|
function createAction( |
|
ref: MutableRef<HTMLTextAreaElement | null>, |
|
callback: (start: number, end: number) => void |
|
): JSX.MouseEventHandler<HTMLDivElement> { |
|
return (e) => { |
|
e.preventDefault(); |
|
if (ref.current) { |
|
callback(ref.current.selectionStart, ref.current.selectionEnd); |
|
ref.current.focus(); |
|
} |
|
} |
|
} |
|
|
|
function insertAt(originalString: string, substring: string, position: number): string { |
|
return originalString.slice(0, position) + substring + originalString.slice(position); |
|
} |