995 lines
31 KiB
TypeScript
995 lines
31 KiB
TypeScript
"use client"
|
|
|
|
import {
|
|
useState,
|
|
useCallback,
|
|
lazy,
|
|
Suspense,
|
|
} from "react"
|
|
import { useEditor, type Editor } from "@tiptap/react"
|
|
import {
|
|
ChevronDown,
|
|
Paperclip,
|
|
Link as LinkIcon,
|
|
Smile,
|
|
HardDrive,
|
|
Image as ImageIcon,
|
|
Lock,
|
|
PenTool,
|
|
MoreVertical,
|
|
Trash2,
|
|
Bold,
|
|
Italic,
|
|
Underline as UnderlineIcon,
|
|
AlignLeft,
|
|
AlignCenter,
|
|
AlignRight,
|
|
AlignJustify,
|
|
List,
|
|
ListOrdered,
|
|
Undo,
|
|
Redo,
|
|
Type,
|
|
Clock,
|
|
Indent,
|
|
Outdent,
|
|
RemoveFormatting,
|
|
Palette,
|
|
ALargeSmall,
|
|
CaseSensitive,
|
|
Send,
|
|
} from "lucide-react"
|
|
import {
|
|
type ComposeState,
|
|
SIGNATURES,
|
|
useComposeActions,
|
|
} from "@/lib/compose-context"
|
|
import { cn, getNextLocalWallClockDate } from "@/lib/utils"
|
|
import {
|
|
MAIL_COMPOSE_BOTTOM_ICON_BTN,
|
|
MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE,
|
|
MAIL_COMPOSE_MENU_SELECTED_CLASS,
|
|
MAIL_COMPOSE_POPOVER_CLASS,
|
|
MAIL_COMPOSE_PRIMARY_SEND_BTN,
|
|
MAIL_COMPOSE_TOOLBAR_BTN,
|
|
MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE,
|
|
MAIL_COMPOSE_TOOLBAR_SEP,
|
|
MAIL_MENU_SURFACE_CLASS,
|
|
} from "@/lib/mail-chrome-classes"
|
|
import { useTheme } from "next-themes"
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuSub,
|
|
DropdownMenuSubContent,
|
|
DropdownMenuSubTrigger,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu"
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover"
|
|
import data from "@emoji-mart/data"
|
|
import { COMPOSE_PORTAL_Z, insertSignatureHtml } from "./compose-shared"
|
|
|
|
const LazyPicker = lazy(() => import("@emoji-mart/react"))
|
|
|
|
function ComposeEmojiPicker({ onSelect }: { onSelect: (emoji: { native: string }) => void }) {
|
|
const { resolvedTheme } = useTheme()
|
|
return (
|
|
<Suspense fallback={<div className="flex h-[435px] w-[352px] items-center justify-center text-sm text-muted-foreground">Chargement…</div>}>
|
|
<LazyPicker
|
|
data={data}
|
|
onEmojiSelect={onSelect}
|
|
locale="fr"
|
|
theme={resolvedTheme === "dark" ? "dark" : "light"}
|
|
previewPosition="none"
|
|
skinTonePosition="search"
|
|
set="native"
|
|
/>
|
|
</Suspense>
|
|
)
|
|
}
|
|
|
|
function AlignmentDropdown({
|
|
editor,
|
|
btnClass,
|
|
activeClass,
|
|
}: {
|
|
editor: NonNullable<ReturnType<typeof useEditor>>
|
|
btnClass: string
|
|
activeClass: string
|
|
}) {
|
|
const currentIcon = editor.isActive({ textAlign: "center" })
|
|
? AlignCenter
|
|
: editor.isActive({ textAlign: "right" })
|
|
? AlignRight
|
|
: editor.isActive({ textAlign: "justify" })
|
|
? AlignJustify
|
|
: AlignLeft
|
|
const CurrentIcon = currentIcon
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn(btnClass, "w-auto gap-0.5 px-1")}
|
|
title="Alignement"
|
|
>
|
|
<CurrentIcon className="h-4 w-4" />
|
|
<ChevronDown className="h-3 w-3" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
className={cn("min-w-[160px]", COMPOSE_PORTAL_Z)}
|
|
>
|
|
<DropdownMenuItem
|
|
onSelect={() => editor.chain().focus().setTextAlign("left").run()}
|
|
className={cn(editor.isActive({ textAlign: "left" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
|
|
>
|
|
<AlignLeft className="h-4 w-4" /> Aligner à gauche
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={() => editor.chain().focus().setTextAlign("center").run()}
|
|
className={cn(editor.isActive({ textAlign: "center" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
|
|
>
|
|
<AlignCenter className="h-4 w-4" /> Centrer
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={() => editor.chain().focus().setTextAlign("right").run()}
|
|
className={cn(editor.isActive({ textAlign: "right" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
|
|
>
|
|
<AlignRight className="h-4 w-4" /> Aligner à droite
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={() => editor.chain().focus().setTextAlign("justify").run()}
|
|
className={cn(editor.isActive({ textAlign: "justify" }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE)}
|
|
>
|
|
<AlignJustify className="h-4 w-4" /> Justifier
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)
|
|
}
|
|
|
|
const FONT_FAMILIES = [
|
|
{ label: "Sans Serif", value: "sans-serif" },
|
|
{ label: "Serif", value: "serif" },
|
|
{ label: "Monospace", value: "monospace" },
|
|
{ label: "Cursive", value: "cursive" },
|
|
{ label: "Comic Sans MS", value: "Comic Sans MS, cursive" },
|
|
{ label: "Garamond", value: "Garamond, serif" },
|
|
{ label: "Georgia", value: "Georgia, serif" },
|
|
{ label: "Impact", value: "Impact, sans-serif" },
|
|
{ label: "Tahoma", value: "Tahoma, sans-serif" },
|
|
{ label: "Trebuchet MS", value: "Trebuchet MS, sans-serif" },
|
|
{ label: "Verdana", value: "Verdana, sans-serif" },
|
|
]
|
|
|
|
const FONT_SIZES = [
|
|
{ label: "Très petit", value: "10px" },
|
|
{ label: "Petit", value: "13px" },
|
|
{ label: "Normal", value: "" },
|
|
{ label: "Grand", value: "18px" },
|
|
{ label: "Très grand", value: "24px" },
|
|
{ label: "Énorme", value: "32px" },
|
|
]
|
|
|
|
const TEXT_COLORS = [
|
|
"#000000", "#434343", "#666666", "#999999", "#cccccc", "#efefef", "#f3f3f3", "#ffffff",
|
|
"#fb4934", "#fe8019", "#fabd2f", "#b8bb26", "#8ec07c", "#83a598", "#d3869b", "#ebdbb2",
|
|
"#cc241d", "#d65d0e", "#d79921", "#98971a", "#689d6a", "#458588", "#b16286", "#a89984",
|
|
"#9d0006", "#af3a03", "#b57614", "#79740e", "#427b58", "#076678", "#8f3f71", "#7c6f64",
|
|
]
|
|
|
|
function FontDropdown({
|
|
editor,
|
|
btnClass,
|
|
}: {
|
|
editor: NonNullable<ReturnType<typeof useEditor>>
|
|
btnClass: string
|
|
}) {
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button type="button" className={cn(btnClass, "w-auto gap-0.5 px-1")} title="Police">
|
|
<CaseSensitive className="h-4 w-4" />
|
|
<ChevronDown className="h-3 w-3" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
className={cn("max-h-[280px] min-w-[180px] overflow-y-auto", COMPOSE_PORTAL_Z)}
|
|
>
|
|
{FONT_FAMILIES.map((f) => (
|
|
<DropdownMenuItem
|
|
key={f.value}
|
|
onSelect={() => editor.chain().focus().setMark("textStyle", { fontFamily: f.value }).run()}
|
|
style={{ fontFamily: f.value }}
|
|
className={cn(
|
|
editor.isActive("textStyle", { fontFamily: f.value }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE
|
|
)}
|
|
>
|
|
{f.label}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)
|
|
}
|
|
|
|
function FontSizeDropdown({
|
|
editor,
|
|
btnClass,
|
|
}: {
|
|
editor: NonNullable<ReturnType<typeof useEditor>>
|
|
btnClass: string
|
|
}) {
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button type="button" className={cn(btnClass, "w-auto gap-0.5 px-1")} title="Taille du texte">
|
|
<ALargeSmall className="h-4 w-4" />
|
|
<ChevronDown className="h-3 w-3" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
className={cn("min-w-[140px]", COMPOSE_PORTAL_Z)}
|
|
>
|
|
{FONT_SIZES.map((s) => (
|
|
<DropdownMenuItem
|
|
key={s.label}
|
|
onSelect={() => {
|
|
if (s.value) {
|
|
editor.chain().focus().setMark("textStyle", { fontSize: s.value }).run()
|
|
} else {
|
|
editor.chain().focus().setMark("textStyle", { fontSize: null }).removeEmptyTextStyle().run()
|
|
}
|
|
}}
|
|
style={s.value ? { fontSize: s.value } : undefined}
|
|
className={cn(
|
|
s.value && editor.isActive("textStyle", { fontSize: s.value }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE
|
|
)}
|
|
>
|
|
{s.label}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)
|
|
}
|
|
|
|
function ColorDropdown({
|
|
editor,
|
|
btnClass,
|
|
}: {
|
|
editor: NonNullable<ReturnType<typeof useEditor>>
|
|
btnClass: string
|
|
}) {
|
|
const [tab, setTab] = useState<"text" | "bg">("text")
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button type="button" className={cn(btnClass, "w-auto gap-0.5 px-1")} title="Couleur du texte">
|
|
<Palette className="h-4 w-4" />
|
|
<ChevronDown className="h-3 w-3" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
className={cn("w-[268px] p-2", COMPOSE_PORTAL_Z)}
|
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
>
|
|
<div className="mb-2 flex gap-1 border-b border-border pb-2">
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex-1 rounded px-2 py-1 text-xs font-medium transition-colors",
|
|
tab === "text" ? MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE : "text-[#5f6368] hover:bg-[#f1f3f4]"
|
|
)}
|
|
onClick={() => setTab("text")}
|
|
>
|
|
Couleur du texte
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex-1 rounded px-2 py-1 text-xs font-medium transition-colors",
|
|
tab === "bg" ? MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE : "text-[#5f6368] hover:bg-[#f1f3f4]"
|
|
)}
|
|
onClick={() => setTab("bg")}
|
|
>
|
|
Couleur de fond
|
|
</button>
|
|
</div>
|
|
<div className="grid grid-cols-8 gap-1">
|
|
{TEXT_COLORS.map((color) => (
|
|
<button
|
|
key={`${tab}-${color}`}
|
|
type="button"
|
|
className="h-6 w-6 rounded border border-border hover:scale-110 transition-transform"
|
|
style={{ backgroundColor: color }}
|
|
title={color}
|
|
onClick={() => {
|
|
if (tab === "text") {
|
|
editor.chain().focus().setColor(color).run()
|
|
} else {
|
|
editor.chain().focus().setMark("textStyle", { backgroundColor: color }).run()
|
|
}
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="mt-2 w-full rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent transition-colors"
|
|
onClick={() => {
|
|
if (tab === "text") {
|
|
editor.chain().focus().unsetColor().run()
|
|
} else {
|
|
editor.chain().focus().setMark("textStyle", { backgroundColor: null }).removeEmptyTextStyle().run()
|
|
}
|
|
}}
|
|
>
|
|
Réinitialiser
|
|
</button>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)
|
|
}
|
|
|
|
export function FormattingToolbar({
|
|
editor,
|
|
}: {
|
|
editor: Editor | null
|
|
}) {
|
|
if (!editor) return null
|
|
|
|
const btnClass = MAIL_COMPOSE_TOOLBAR_BTN
|
|
const activeClass = MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE
|
|
const sep = <span className={MAIL_COMPOSE_TOOLBAR_SEP} aria-hidden />
|
|
|
|
return (
|
|
<div className="compose-toolbar flex flex-wrap items-center border-t border-border bg-muted px-1 py-1">
|
|
{/* Undo / Redo */}
|
|
<button
|
|
type="button"
|
|
className={btnClass}
|
|
onClick={() => editor.chain().focus().undo().run()}
|
|
disabled={!editor.can().undo()}
|
|
title="Annuler"
|
|
>
|
|
<Undo className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={btnClass}
|
|
onClick={() => editor.chain().focus().redo().run()}
|
|
disabled={!editor.can().redo()}
|
|
title="Rétablir"
|
|
>
|
|
<Redo className="h-4 w-4" />
|
|
</button>
|
|
|
|
{sep}
|
|
|
|
{/* Font */}
|
|
<FontDropdown editor={editor} btnClass={btnClass} />
|
|
|
|
{sep}
|
|
|
|
{/* Font size */}
|
|
<FontSizeDropdown editor={editor} btnClass={btnClass} />
|
|
|
|
{sep}
|
|
|
|
{/* Bold, Italic, Underline, Colors */}
|
|
<button
|
|
type="button"
|
|
className={cn(btnClass, editor.isActive("bold") && activeClass)}
|
|
onClick={() => editor.chain().focus().toggleMark("bold").run()}
|
|
title="Gras"
|
|
>
|
|
<Bold className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={cn(btnClass, editor.isActive("italic") && activeClass)}
|
|
onClick={() => editor.chain().focus().toggleMark("italic").run()}
|
|
title="Italique"
|
|
>
|
|
<Italic className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={cn(btnClass, editor.isActive("underline") && activeClass)}
|
|
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
|
title="Souligné"
|
|
>
|
|
<UnderlineIcon className="h-4 w-4" />
|
|
</button>
|
|
<ColorDropdown editor={editor} btnClass={btnClass} />
|
|
|
|
{sep}
|
|
|
|
{/* Alignment dropdown, lists, indent/outdent, remove formatting */}
|
|
<AlignmentDropdown editor={editor} btnClass={btnClass} activeClass={activeClass} />
|
|
<button
|
|
type="button"
|
|
className={cn(btnClass, editor.isActive("orderedList") && activeClass)}
|
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
title="Liste numérotée"
|
|
>
|
|
<ListOrdered className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={cn(btnClass, editor.isActive("bulletList") && activeClass)}
|
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
title="Liste à puces"
|
|
>
|
|
<List className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={btnClass}
|
|
onClick={() => {
|
|
try { editor.chain().focus().liftListItem("listItem").run() } catch { /* not in list */ }
|
|
}}
|
|
title="Désindenter"
|
|
>
|
|
<Outdent className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={btnClass}
|
|
onClick={() => {
|
|
try { editor.chain().focus().sinkListItem("listItem").run() } catch { /* not in list */ }
|
|
}}
|
|
title="Indenter"
|
|
>
|
|
<Indent className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={btnClass}
|
|
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
|
|
title="Supprimer la mise en forme"
|
|
>
|
|
<RemoveFormatting className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ComposeEmojiButton({
|
|
editor,
|
|
}: {
|
|
editor: Editor | null
|
|
}) {
|
|
const [open, setOpen] = useState(false)
|
|
|
|
const handleSelect = useCallback(
|
|
(emoji: { native: string }) => {
|
|
editor?.chain().focus().insertContent(emoji.native).run()
|
|
setOpen(false)
|
|
},
|
|
[editor]
|
|
)
|
|
|
|
if (!editor) return null
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
|
|
title="Insérer un emoji"
|
|
>
|
|
<Smile className="h-[18px] w-[18px]" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
align="start"
|
|
side="top"
|
|
className={cn("w-auto border-0 bg-popover p-0 shadow-xl", COMPOSE_PORTAL_Z)}
|
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
>
|
|
<ComposeEmojiPicker onSelect={handleSelect} />
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
function ComposeLinkButton({
|
|
editor,
|
|
}: {
|
|
editor: Editor | null
|
|
}) {
|
|
const [open, setOpen] = useState(false)
|
|
const [url, setUrl] = useState("")
|
|
const [text, setText] = useState("")
|
|
|
|
if (!editor) return null
|
|
|
|
const isLinkActive = editor.isActive("link")
|
|
|
|
const handleToggle = () => {
|
|
if (isLinkActive) {
|
|
editor.chain().focus().extendMarkRange("link").unsetLink().run()
|
|
return
|
|
}
|
|
setOpen(true)
|
|
}
|
|
|
|
const handleOpen = (isOpen: boolean) => {
|
|
if (isOpen) {
|
|
const { from, to, empty } = editor.state.selection
|
|
if (isLinkActive) {
|
|
const attrs = editor.getAttributes("link")
|
|
setUrl(attrs.href || "")
|
|
const selectedText = editor.state.doc.textBetween(from, to, " ")
|
|
setText(selectedText)
|
|
} else if (!empty) {
|
|
const selectedText = editor.state.doc.textBetween(from, to, " ")
|
|
setText(selectedText)
|
|
setUrl("")
|
|
} else {
|
|
setText("")
|
|
setUrl("")
|
|
}
|
|
}
|
|
setOpen(isOpen)
|
|
}
|
|
|
|
const handleInsert = () => {
|
|
if (!url.trim()) return
|
|
const href = url.match(/^https?:\/\//) ? url : `https://${url}`
|
|
|
|
const { empty } = editor.state.selection
|
|
|
|
if (empty && !isLinkActive) {
|
|
const displayText = text.trim() || href
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.insertContent(`<a href="${href}">${displayText}</a>`)
|
|
.run()
|
|
} else {
|
|
if (text.trim() && text.trim() !== editor.state.doc.textBetween(
|
|
editor.state.selection.from,
|
|
editor.state.selection.to,
|
|
" "
|
|
)) {
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.deleteSelection()
|
|
.insertContent(`<a href="${href}">${text.trim()}</a>`)
|
|
.run()
|
|
} else {
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.extendMarkRange("link")
|
|
.setLink({ href })
|
|
.run()
|
|
}
|
|
}
|
|
|
|
setOpen(false)
|
|
setUrl("")
|
|
setText("")
|
|
}
|
|
|
|
const handleRemoveLink = () => {
|
|
editor.chain().focus().extendMarkRange("link").unsetLink().run()
|
|
setOpen(false)
|
|
setUrl("")
|
|
setText("")
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={handleOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
if (isLinkActive) {
|
|
e.preventDefault()
|
|
handleToggle()
|
|
}
|
|
}}
|
|
className={cn(
|
|
MAIL_COMPOSE_BOTTOM_ICON_BTN,
|
|
isLinkActive && MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE
|
|
)}
|
|
title={isLinkActive ? "Supprimer le lien" : "Insérer un lien"}
|
|
>
|
|
<LinkIcon className="h-[18px] w-[18px]" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
align="start"
|
|
side="top"
|
|
className={cn("w-[340px]", MAIL_COMPOSE_POPOVER_CLASS, COMPOSE_PORTAL_Z)}
|
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
>
|
|
<div className="flex flex-col gap-2.5">
|
|
<div className="text-sm font-medium text-foreground">
|
|
{isLinkActive ? "Modifier le lien" : "Insérer un lien"}
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-xs text-muted-foreground">Texte à afficher</label>
|
|
<input
|
|
type="text"
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
placeholder="Texte du lien"
|
|
className="h-8 rounded border border-border bg-mail-surface px-2 text-sm text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-xs text-muted-foreground">URL</label>
|
|
<input
|
|
type="text"
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
placeholder="https://example.com"
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault()
|
|
handleInsert()
|
|
}
|
|
}}
|
|
className="h-8 rounded border border-border bg-mail-surface px-2 text-sm text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between pt-1">
|
|
{isLinkActive ? (
|
|
<button
|
|
type="button"
|
|
onClick={handleRemoveLink}
|
|
className="text-sm text-destructive hover:text-destructive/90 transition-colors"
|
|
>
|
|
Supprimer le lien
|
|
</button>
|
|
) : (
|
|
<span />
|
|
)}
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(false)}
|
|
className="rounded px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent transition-colors"
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleInsert}
|
|
disabled={!url.trim()}
|
|
className={cn("rounded px-3 py-1.5 text-sm font-medium disabled:opacity-50", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
|
|
>
|
|
{isLinkActive ? "Modifier" : "Insérer"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
export function ComposeSignatureButton({
|
|
editor,
|
|
compose,
|
|
}: {
|
|
editor: Editor | null
|
|
compose: ComposeState
|
|
}) {
|
|
const { updateCompose } = useComposeActions()
|
|
|
|
const replaceSignature = useCallback(
|
|
(sigId: string | null) => {
|
|
if (!editor) return
|
|
const newHtml = insertSignatureHtml(editor.getHTML(), sigId)
|
|
editor.commands.setContent(newHtml)
|
|
updateCompose(compose.id, { bodyHtml: newHtml, signatureId: sigId })
|
|
},
|
|
[editor, compose.id, updateCompose]
|
|
)
|
|
|
|
const toggleAutoInsert = useCallback(() => {
|
|
const newVal = !compose.autoInsertSignature
|
|
updateCompose(compose.id, { autoInsertSignature: newVal })
|
|
if (!newVal) {
|
|
replaceSignature(null)
|
|
} else {
|
|
const sigId = compose.from.defaultSignatureId
|
|
if (sigId) replaceSignature(sigId)
|
|
}
|
|
}, [compose.autoInsertSignature, compose.from.defaultSignatureId, compose.id, updateCompose, replaceSignature])
|
|
|
|
if (!editor) return null
|
|
|
|
return (
|
|
<DropdownMenu modal={false}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
|
|
title="Insérer une signature"
|
|
>
|
|
<PenTool className="h-[18px] w-[18px]" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
side="top"
|
|
className={cn(MAIL_MENU_SURFACE_CLASS, "min-w-[220px]", COMPOSE_PORTAL_Z)}
|
|
>
|
|
<DropdownMenuItem
|
|
onSelect={(e) => {
|
|
e.preventDefault()
|
|
toggleAutoInsert()
|
|
}}
|
|
className="gap-2"
|
|
>
|
|
<span className="flex h-4 w-4 items-center justify-center">
|
|
{compose.autoInsertSignature && <span className="text-xs">✓</span>}
|
|
</span>
|
|
Insérer automatiquement
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onSelect={() => replaceSignature(null)}
|
|
className={cn("gap-2", !compose.signatureId && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
|
|
>
|
|
<span className="flex h-4 w-4 items-center justify-center">
|
|
{!compose.signatureId && <span className="text-xs">✓</span>}
|
|
</span>
|
|
Aucune signature
|
|
</DropdownMenuItem>
|
|
{SIGNATURES.map((sig) => (
|
|
<DropdownMenuItem
|
|
key={sig.id}
|
|
onSelect={() => replaceSignature(sig.id)}
|
|
className={cn("gap-2", compose.signatureId === sig.id && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
|
|
>
|
|
<span className="flex h-4 w-4 items-center justify-center">
|
|
{compose.signatureId === sig.id && <span className="text-xs">✓</span>}
|
|
</span>
|
|
{sig.name}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)
|
|
}
|
|
|
|
export interface ComposeBottomToolbarProps {
|
|
compose: ComposeState
|
|
editor: Editor | null
|
|
isEditingScheduled: boolean
|
|
showFormatting: boolean
|
|
sendMenuOpen: boolean
|
|
setShowFormatting: (v: boolean | ((prev: boolean) => boolean)) => void
|
|
setSendMenuOpen: (v: boolean) => void
|
|
handleSend: () => void
|
|
saveScheduledEdit: () => void | Promise<void>
|
|
sendScheduledFromEditNow: () => void | Promise<void>
|
|
applyScheduledPlanAt: (sendAt: Date) => void | Promise<void>
|
|
submitScheduledSendAt: (sendAt: Date) => void | Promise<void>
|
|
handleClose: () => void
|
|
fileInputRef: React.RefObject<HTMLInputElement | null>
|
|
imageInputRef: React.RefObject<HTMLInputElement | null>
|
|
}
|
|
|
|
export function ComposeBottomToolbar(props: ComposeBottomToolbarProps) {
|
|
const {
|
|
compose,
|
|
editor,
|
|
isEditingScheduled,
|
|
showFormatting,
|
|
sendMenuOpen,
|
|
setShowFormatting,
|
|
setSendMenuOpen,
|
|
handleSend,
|
|
saveScheduledEdit,
|
|
sendScheduledFromEditNow,
|
|
applyScheduledPlanAt,
|
|
submitScheduledSendAt,
|
|
handleClose,
|
|
fileInputRef,
|
|
imageInputRef,
|
|
} = props
|
|
return (
|
|
<div className="flex shrink-0 items-center gap-1 border-t border-border px-2 py-1.5">
|
|
{/* Send / save + dropdown */}
|
|
<div className="flex items-center">
|
|
{isEditingScheduled ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => void saveScheduledEdit()}
|
|
className={cn("rounded-l-full px-5 text-sm font-medium", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
|
|
>
|
|
Enregistrer
|
|
</button>
|
|
<DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn("rounded-r-full border-l border-primary-foreground/30 px-1.5", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
|
|
>
|
|
<ChevronDown className="h-4 w-4" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
void sendScheduledFromEditNow()
|
|
}}
|
|
>
|
|
<Send className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
|
|
Envoyer maintenant
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger className="[&>svg:last-child]:text-muted-foreground">
|
|
<Clock className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
|
|
Planifier
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
void applyScheduledPlanAt(
|
|
new Date(Date.now() + 60 * 60 * 1000)
|
|
)
|
|
}}
|
|
>
|
|
<Clock className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
|
|
Envoyer dans une heure
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
void applyScheduledPlanAt(
|
|
getNextLocalWallClockDate(9, 0)
|
|
)
|
|
}}
|
|
>
|
|
<Clock className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
|
|
Envoyer à 9h
|
|
</DropdownMenuItem>
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</>
|
|
) : (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={handleSend}
|
|
className={cn("rounded-l-full px-5 text-sm font-medium", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
|
|
>
|
|
Envoyer
|
|
</button>
|
|
<DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn("rounded-r-full border-l border-primary-foreground/30 px-1.5", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
|
|
>
|
|
<ChevronDown className="h-4 w-4" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
void submitScheduledSendAt(
|
|
new Date(Date.now() + 60 * 60 * 1000)
|
|
)
|
|
}}
|
|
>
|
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
Envoyer dans une heure
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
void submitScheduledSendAt(
|
|
getNextLocalWallClockDate(9, 0)
|
|
)
|
|
}}
|
|
>
|
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
Envoyer à 9h
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => setSendMenuOpen(false)}>
|
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
Programmer l'envoi
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Toolbar icons */}
|
|
<div className="flex items-center gap-0.5 ml-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowFormatting(!showFormatting)}
|
|
className={cn(
|
|
MAIL_COMPOSE_BOTTOM_ICON_BTN,
|
|
showFormatting && MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE
|
|
)}
|
|
title="Options de mise en forme"
|
|
>
|
|
<Type className="h-[18px] w-[18px]" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
|
|
title="Joindre des fichiers"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
<Paperclip className="h-[18px] w-[18px]" />
|
|
</button>
|
|
<ComposeLinkButton editor={editor} />
|
|
<ComposeEmojiButton editor={editor} />
|
|
<button
|
|
type="button"
|
|
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
|
|
title="Insérer des fichiers avec Google Drive"
|
|
>
|
|
<HardDrive className="h-[18px] w-[18px]" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
|
|
title="Insérer une photo"
|
|
onClick={() => imageInputRef.current?.click()}
|
|
>
|
|
<ImageIcon className="h-[18px] w-[18px]" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
|
|
title="Activer le mode confidentiel"
|
|
>
|
|
<Lock className="h-[18px] w-[18px]" />
|
|
</button>
|
|
<ComposeSignatureButton editor={editor} compose={compose} />
|
|
<button
|
|
type="button"
|
|
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
|
|
title="Plus d'options"
|
|
>
|
|
<MoreVertical className="h-[18px] w-[18px]" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1" />
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleClose}
|
|
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
|
|
title="Supprimer le brouillon"
|
|
>
|
|
<Trash2 className="h-[18px] w-[18px]" />
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|