wow
Some checks are pending
E2E / Playwright e2e (push) Waiting to run

This commit is contained in:
R3D347HR4Y 2026-06-11 01:22:40 +02:00
parent 2a7c153748
commit 303b2b1074
208 changed files with 22553 additions and 1082 deletions

View File

@ -21,3 +21,5 @@ OIDC_CLIENT_SECRET=changeme
NEXT_PUBLIC_ONLYOFFICE_URL=http://localhost/office
# Rich text editor (TipTap + Hocuspocus — docs texte)
NEXT_PUBLIC_HOCUSPOCUS_URL=ws://localhost/collab
# UltiAI (chemin proxy OpenWebUI — même origine)
NEXT_PUBLIC_AI_PUBLIC_PATH=/ai

View File

@ -0,0 +1,11 @@
/**
* Sauvegarde factice pour la démo publique UltiDocs.
* Zéro rétention : le corps de la requête est ignoré.
*/
export async function PUT() {
return new Response(null, { status: 204 })
}
export async function POST() {
return new Response(null, { status: 204 })
}

50
app/chat/page.tsx Normal file
View File

@ -0,0 +1,50 @@
"use client"
import { Sparkles } from "lucide-react"
import { AiChatIframe } from "@/components/ai/ai-chat-iframe"
import { useAiConfig, useAiQuota } from "@/lib/api/hooks/use-ai-queries"
export default function ChatPage() {
const { data: config, isLoading } = useAiConfig()
const { data: quota } = useAiQuota(Boolean(config?.enabled))
if (isLoading) {
return (
<div className="flex h-dvh items-center justify-center text-sm text-muted-foreground">
Chargement UltiAI
</div>
)
}
if (!config?.enabled) {
return (
<div className="flex h-dvh flex-col items-center justify-center gap-2 px-6 text-center">
<Sparkles className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
UltiAI n&apos;est pas activé. Activez le plugin dans l&apos;administration.
</p>
</div>
)
}
return (
<div className="flex h-dvh flex-col">
<header className="flex items-center justify-between border-b px-4 py-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Sparkles className="h-4 w-4 text-[#1a73e8]" />
UltiAI
</div>
{quota ? (
<span className="text-xs text-muted-foreground">
{quota.requests_remaining}/{quota.requests_limit} requêtes aujourd&apos;hui
</span>
) : null}
</header>
<AiChatIframe
publicPath={config.public_path}
context={{ app: "standalone", temporary: false }}
className="min-h-0 flex-1 border-0"
/>
</div>
)
}

18
app/demo/docs/page.tsx Normal file
View File

@ -0,0 +1,18 @@
import type { Metadata } from "next"
import { DemoDocsEditor } from "@/components/demo/demo-docs-editor"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = {
...suitePageMetadata({
app: "drive",
title: "Démo UltiDocs",
absoluteTitle: true,
description:
"Essayez l'éditeur de documents UltiDocs sans compte — démo interactive, zéro rétention.",
}),
robots: { index: false },
}
export default function DemoDocsPage() {
return <DemoDocsEditor />
}

18
app/demo/mail/page.tsx Normal file
View File

@ -0,0 +1,18 @@
import type { Metadata } from "next"
import { DemoMailApp } from "@/components/demo/demo-mail-app"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = {
...suitePageMetadata({
app: "mail",
title: "Démo Ultimail",
absoluteTitle: true,
description:
"Essayez la messagerie Ultimail sans compte — démo interactive, zéro rétention.",
}),
robots: { index: false },
}
export default function DemoMailPage() {
return <DemoMailApp />
}

View File

@ -0,0 +1,20 @@
"use client"
import { useParams } from "next/navigation"
import { UltidrawEditor } from "@/components/drive/ultidraw-editor"
import { isDriveFileIdSegment } from "@/lib/drive/drive-url"
export default function DriveDrawEditPage() {
const params = useParams()
const fileId = params.fileId as string
if (!isDriveFileIdSegment(fileId)) {
return (
<div className="flex h-dvh items-center justify-center p-8 text-sm text-muted-foreground">
Identifiant de dessin invalide
</div>
)
}
return <UltidrawEditor fileId={fileId} />
}

View File

@ -1,25 +1,37 @@
"use client"
import { useParams } from "next/navigation"
import { useParams, useRouter } from "next/navigation"
import { useEffect, useState } from "react"
import { Loader2, Lock } from "lucide-react"
import { Lock } from "lucide-react"
import {
PublicShareChrome,
PublicShareViewPanel,
} from "@/components/drive/public-share-view"
import { DocsLoadingSplash } from "@/components/drive/richtext/docs-loading-splash"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { usePublicShare } from "@/lib/api/hooks/use-public-share-queries"
import { folderPathFromPublicSegments } from "@/lib/api/public-share"
import {
shouldOpenInOnlyOffice,
shouldOpenInRichTextEditor,
shouldOpenInUltidrawEditor,
} from "@/lib/drive/drive-preview"
import { sharePermCanEdit } from "@/lib/drive/drive-share-permissions"
import { buildPublicShareEditHref } from "@/lib/drive/public-share-url"
import { sharePathLooksLikeEditorFile } from "@/lib/drive/share-path-looks-like-editor"
export default function PublicSharePage() {
const params = useParams()
const router = useRouter()
const token = String(params.token ?? "")
const pathSegments = params.path as string[] | undefined
const path = folderPathFromPublicSegments(pathSegments)
const pathHintsEditor = sharePathLooksLikeEditorFile(path)
const [passwordInput, setPasswordInput] = useState("")
const [password, setPassword] = useState<string | undefined>(undefined)
const [redirectingToEditor, setRedirectingToEditor] = useState(false)
const { data, isLoading, isError, error, refetch, isFetching } = usePublicShare(
token,
@ -30,12 +42,48 @@ export default function PublicSharePage() {
const needsPassword =
isError && error instanceof Error && error.message === "password_required"
const file = data?.item_type === "file" ? data.file : null
const isEditorFile = Boolean(
file &&
(shouldOpenInRichTextEditor(file) ||
shouldOpenInUltidrawEditor(file) ||
shouldOpenInOnlyOffice(file))
)
const showDocsSplash =
pathHintsEditor || redirectingToEditor || (Boolean(data) && isEditorFile)
useEffect(() => {
if (password && typeof window !== "undefined") {
sessionStorage.setItem(`public-share-pw:${token}`, password)
}
}, [password, token])
useEffect(() => {
if (!file || !data || !isEditorFile) return
setRedirectingToEditor(true)
const canEdit = sharePermCanEdit(data.permissions ?? 1)
const returnTo =
typeof window !== "undefined"
? window.location.pathname + window.location.search
: undefined
const editor = shouldOpenInUltidrawEditor(file)
? "ultidraw"
: shouldOpenInRichTextEditor(file)
? "richtext"
: "office"
router.replace(
buildPublicShareEditHref(
token,
file.path,
returnTo,
canEdit ? "edit" : "view",
file.name,
editor,
data.item_type
)
)
}, [data, file, isEditorFile, router, token])
const submitPassword = (event: React.FormEvent) => {
event.preventDefault()
const trimmed = passwordInput.trim()
@ -43,12 +91,19 @@ export default function PublicSharePage() {
setPassword(trimmed)
}
if (
showDocsSplash &&
!needsPassword &&
(isLoading || (isFetching && !data) || redirectingToEditor || isEditorFile)
) {
const splashTitle = file?.name ?? path.split("/").filter(Boolean).pop()
return <DocsLoadingSplash phase="opening" title={splashTitle || undefined} />
}
return (
<PublicShareChrome>
{isLoading || (isFetching && !data) ? (
<div className="flex min-h-[40vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<DocsLoadingSplash phase="opening" />
) : needsPassword ? (
<div className="mx-auto flex min-h-[40vh] max-w-md flex-col justify-center px-4 py-12">
<div className="rounded-2xl border border-border bg-mail-surface p-6 shadow-sm">

View File

@ -4,7 +4,8 @@ import { useParams, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
import { PublicOfficeEditor } from "@/components/drive/public-office-editor"
import { PublicRichTextEditor } from "@/components/drive/public-richtext-editor"
import { shouldOpenInRichTextEditor } from "@/lib/drive/drive-preview"
import { PublicUltidrawEditor } from "@/components/drive/public-ultidraw-editor"
import { shouldOpenInRichTextEditor, shouldOpenInUltidrawEditor } from "@/lib/drive/drive-preview"
import { filePathFromPublicEditSegments, readPublicShareRootType } from "@/lib/drive/public-share-url"
import type { PublicShareRootType } from "@/lib/drive/public-share-url"
@ -36,11 +37,27 @@ export default function PublicShareEditPage() {
return sessionStorage.getItem(`public-share-pw:${token}`) ?? undefined
})
const fileName = fileDisplayName ?? filePath.split("/").pop() ?? ""
const useUltidraw =
editorParam === "ultidraw" || shouldOpenInUltidrawEditor({ name: fileName })
const useRichText =
editorParam === "richtext" ||
shouldOpenInRichTextEditor({
name: fileDisplayName ?? filePath.split("/").pop() ?? "",
})
editorParam === "richtext" || shouldOpenInRichTextEditor({ name: fileName })
if (useUltidraw) {
return (
<PublicUltidrawEditor
token={token}
filePath={filePath}
password={password}
returnTo={returnTo}
mode={mode}
fileDisplayName={fileDisplayName}
shareRoot={shareRoot}
/>
)
}
if (useRichText) {
return (

View File

@ -2,6 +2,8 @@
@import 'tw-animate-css';
@import '../styles/onlyoffice-theme.css';
@import '../styles/richtext-editor.css';
@import '../styles/docs-print.css';
@import '../styles/landing.css';
@custom-variant dark (&:is(.dark *));
@ -416,6 +418,18 @@ body {
}
}
@keyframes splash-logo-spin {
0% {
transform: rotate(0deg);
}
14% {
transform: rotate(36deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes splash-loader-progress {
0% {
transform: translateX(-104%);

View File

@ -10,7 +10,6 @@ import {
CardFooter,
CardHeader,
} from "@/components/ui/card"
import { UltiMailLogo } from "@/components/ultimail-logo"
import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config"
import { cn } from "@/lib/utils"
@ -32,10 +31,23 @@ function LoginContent() {
<div className="ultimail-login-card-frame mx-auto w-full max-w-sm">
<Card className={LOGIN_CARD_CLASS}>
<CardHeader className="gap-4 px-0 text-center sm:px-0">
<UltiMailLogo variant="stacked" href={null} />
<div className="flex flex-col items-center gap-3 py-4">
<img
src="/ultisuite-mark.svg"
alt=""
width={72}
height={72}
draggable={false}
className="h-16 w-16 select-none"
aria-hidden
/>
<span className="text-2xl font-bold tracking-tight">
Ulti<span className="text-[#4285F4]">Suite</span>
</span>
</div>
<CardDescription>
Connecte-toi avec ton compte Ulti (Authentik) pour accéder à la
messagerie.
Connecte-toi avec ton compte Ulti (Authentik) pour accéder à ta
suite : mail, drive, contacts et IA.
</CardDescription>
{error ? (
<p className="text-sm text-destructive" role="alert">

View File

@ -45,6 +45,7 @@ import { MailNotificationsBridge } from "@/components/gmail/mail-notifications-b
import { useWebSocket } from "@/lib/api/ws"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { FilePreviewDialog } from "@/components/drive/file-preview-dialog"
import { AiChatPanel } from "@/components/ai/ai-chat-panel"
const MAIL_SETTINGS_PATH = "/mail/settings"
@ -191,6 +192,7 @@ function MailAppInner() {
<RightPanel />
</div>
<ContactsPanel />
<AiChatPanel />
</div>
{!splitView ? (
<MobileBottomBar

View File

@ -1,4 +1,15 @@
import type { Metadata } from "next"
import { redirect } from "next/navigation"
import { LandingPage } from "@/components/landing/landing-page"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = suitePageMetadata({
app: "suite",
title: "UltiSuite — La suite collaborative souveraine et open source",
absoluteTitle: true,
description:
"Mails, fichiers, documents collaboratifs, contacts et IA : l'alternative open source et auto-hébergée à Google Workspace et Microsoft 365.",
})
type HomeSearchParams = Promise<{ mail?: string | string[] }>
@ -13,5 +24,5 @@ export default async function Home({
if (mail && mail.length > 0) {
redirect(`/mail/inbox/message/${encodeURIComponent(mail)}`)
}
redirect("/mail/inbox")
return <LandingPage />
}

View File

@ -20,6 +20,7 @@ import { NextcloudSection } from "@/components/admin/settings/sections/nextcloud
import { MailingSection } from "@/components/admin/settings/sections/mailing-section"
import { OnlyofficeSection } from "@/components/admin/settings/sections/onlyoffice-section"
import { RichtextSection } from "@/components/admin/settings/sections/richtext-section"
import { AiAssistantSection } from "@/components/admin/settings/sections/ai-assistant-section"
import { AuditSection } from "@/components/admin/settings/sections/audit-section"
const SECTIONS: Record<AdminSettingsSectionId, React.ComponentType> = {
@ -38,6 +39,7 @@ const SECTIONS: Record<AdminSettingsSectionId, React.ComponentType> = {
mailing: MailingSection,
onlyoffice: OnlyofficeSection,
richtext: RichtextSection,
"ai-assistant": AiAssistantSection,
audit: AuditSection,
}

View File

@ -0,0 +1,99 @@
"use client"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
export function AiAssistantSection() {
const aiAssistant = useOrgSettingsStore((s) => s.aiAssistant)
const setAiAssistant = useOrgSettingsStore((s) => s.setAiAssistant)
const effective = useOrgSettingsStore((s) => s.meta?.effective.ai_assistant)
const enabled = effective?.enabled ?? aiAssistant.enabled
return (
<OrgSettingsSection
title="UltiAI"
description="Assistant IA intégré (OpenWebUI) avec gateway LLM, tools et sync Nextcloud."
policySection="ai_assistant"
>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-4">
<div>
<CardTitle className="text-sm font-medium">Assistant IA</CardTitle>
<CardDescription>
Chat standalone et panneaux contextuels mail/drive/contacts.
</CardDescription>
</div>
<Switch
checked={enabled}
onCheckedChange={(v) => setAiAssistant({ enabled: v })}
/>
</div>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label>Chemin public (proxy)</Label>
<Input
value={aiAssistant.public_path}
onChange={(e) => setAiAssistant({ public_path: e.target.value })}
placeholder="/ai"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>URL interne OpenWebUI</Label>
<Input
value={aiAssistant.openwebui_internal_url}
onChange={(e) => setAiAssistant({ openwebui_internal_url: e.target.value })}
placeholder="http://openwebui:8080"
/>
</div>
<div className="space-y-2">
<Label>Modèle par défaut</Label>
<Input
value={aiAssistant.default_model}
onChange={(e) => setAiAssistant({ default_model: e.target.value })}
placeholder="gpt-4o"
/>
</div>
<div className="space-y-2">
<Label>Chemin historique NC</Label>
<Input
value={aiAssistant.chat_nc_path}
onChange={(e) => setAiAssistant({ chat_nc_path: e.target.value })}
placeholder="/.ultimail/ai/chats"
/>
</div>
<div className="flex items-center justify-between gap-4 sm:col-span-2">
<div>
<Label>Embed temporaire par défaut</Label>
<p className="text-xs text-muted-foreground">
Les panneaux mail/drive/contacts ne sauvegardent pas l&apos;historique.
</p>
</div>
<Switch
checked={aiAssistant.embed_default_temporary}
onCheckedChange={(v) => setAiAssistant({ embed_default_temporary: v })}
/>
</div>
<div className="flex items-center justify-between gap-4 sm:col-span-2">
<div>
<Label>Sync historique Nextcloud</Label>
<p className="text-xs text-muted-foreground">
Pipeline OpenWebUI fichiers .ultichat.json sur le drive utilisateur.
</p>
</div>
<Switch
checked={aiAssistant.chat_sync_enabled}
onCheckedChange={(v) => setAiAssistant({ chat_sync_enabled: v })}
/>
</div>
</CardContent>
</Card>
</OrgSettingsSection>
)
}

View File

@ -0,0 +1,55 @@
"use client"
import { useEffect, useMemo, useRef } from "react"
import type { AiChatContext } from "@/lib/ai/chat-context"
import { buildEmbedSearchParams } from "@/lib/ai/chat-context"
import { useTheme } from "next-themes"
type AiChatIframeProps = {
publicPath?: string
context: AiChatContext
className?: string
}
export function AiChatIframe({ publicPath = "/ai", context, className }: AiChatIframeProps) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const { resolvedTheme } = useTheme()
const src = useMemo(() => {
const base = publicPath.replace(/\/$/, "")
const qs = buildEmbedSearchParams(context)
return qs ? `${base}/?${qs}` : `${base}/`
}, [publicPath, context])
useEffect(() => {
const iframe = iframeRef.current
if (!iframe?.contentWindow) return
iframe.contentWindow.postMessage(
{ type: "ULTI_THEME", theme: resolvedTheme === "dark" ? "dark" : "light" },
window.location.origin
)
}, [resolvedTheme])
useEffect(() => {
const iframe = iframeRef.current
if (!iframe?.contentWindow) return
iframe.contentWindow.postMessage(
{
type: "ULTI_CONTEXT_UPDATE",
context,
systemPrompt: context.systemPromptExtra,
},
window.location.origin
)
}, [context])
return (
<iframe
ref={iframeRef}
title="UltiAI"
src={src}
className={className}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads"
allow="clipboard-read; clipboard-write"
/>
)
}

View File

@ -0,0 +1,46 @@
"use client"
import { X, Sparkles } from "lucide-react"
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Button } from "@/components/ui/button"
import { AiChatIframe } from "@/components/ai/ai-chat-iframe"
import { useAiPanelStore } from "@/lib/ai/use-ai-panel"
import { useAiConfig, useAiQuota } from "@/lib/api/hooks/use-ai-queries"
export function AiChatPanel() {
const open = useAiPanelStore((s) => s.open)
const context = useAiPanelStore((s) => s.context)
const closePanel = useAiPanelStore((s) => s.closePanel)
const { data: config } = useAiConfig()
const { data: quota } = useAiQuota(open && (config?.enabled ?? false))
if (!config?.enabled) return null
return (
<Sheet open={open} onOpenChange={(v) => !v && closePanel()}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="flex flex-row items-center justify-between border-b px-4 py-3">
<SheetTitle className="flex items-center gap-2 text-base">
<Sparkles className="h-4 w-4 text-[#1a73e8]" />
UltiAI
</SheetTitle>
<div className="flex items-center gap-2">
{quota ? (
<span className="text-xs text-muted-foreground">
{quota.requests_remaining} req. restantes
</span>
) : null}
<Button variant="ghost" size="icon" onClick={closePanel} aria-label="Fermer">
<X className="h-4 w-4" />
</Button>
</div>
</SheetHeader>
<AiChatIframe
publicPath={config.public_path}
context={context}
className="h-full min-h-0 w-full flex-1 border-0"
/>
</SheetContent>
</Sheet>
)
}

View File

@ -0,0 +1,223 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { Editor } from "@tiptap/react"
import { Sparkles, X, GripVertical } from "lucide-react"
import { Button } from "@/components/ui/button"
import { AiChatIframe } from "@/components/ai/ai-chat-iframe"
import {
docsContextFromSnapshot,
docsSystemPromptExtra,
snapshotDocsEditor,
} from "@/lib/ai/docs-context"
import {
applyDocsAction,
extractDocsApplyFromMarkdown,
parseDocsApplyPayload,
} from "@/lib/ai/docs-apply"
import type { AiPostMessage } from "@/lib/ai/chat-context"
import { useAiConfig, useAiQuota } from "@/lib/api/hooks/use-ai-queries"
import {
DOCS_AI_PANEL_MIN_WIDTH_PX,
useDocsAiPanelStore,
} from "@/lib/ai/use-docs-ai-panel"
import { cn } from "@/lib/utils"
export function DocsAiPanel({
editor,
documentPath,
documentTitle,
sourcePath,
editable,
}: {
editor: Editor | null
documentPath: string
documentTitle: string
sourcePath?: string
editable: boolean
}) {
const open = useDocsAiPanelStore((s) => s.open)
const widthPx = useDocsAiPanelStore((s) => s.widthPx)
const setWidthPx = useDocsAiPanelStore((s) => s.setWidthPx)
const closePanel = useDocsAiPanelStore((s) => s.closePanel)
const { data: config } = useAiConfig()
const { data: quota } = useAiQuota(open && (config?.enabled ?? false))
const [contextTick, setContextTick] = useState(0)
const resizeRef = useRef<{ startX: number; startW: number } | null>(null)
useEffect(() => {
if (!editor || !open) return
const bump = () => setContextTick((n) => n + 1)
editor.on("selectionUpdate", bump)
editor.on("update", bump)
return () => {
editor.off("selectionUpdate", bump)
editor.off("update", bump)
}
}, [editor, open])
const context = useMemo(() => {
if (!editor || !open) {
return {
app: "docs" as const,
temporary: true,
drivePath: documentPath,
documentTitle,
sourcePath,
}
}
const snap = snapshotDocsEditor(editor, {
path: documentPath,
title: documentTitle,
sourcePath,
})
return docsContextFromSnapshot(snap, true)
}, [editor, open, documentPath, documentTitle, sourcePath, contextTick])
const handleApplyFromParent = useCallback(
(payload: unknown) => {
if (!editor) return
const cmd = parseDocsApplyPayload(payload)
if (cmd) applyDocsAction(editor, cmd)
},
[editor]
)
useEffect(() => {
if (!open || !editor) return
const onMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return
const data = event.data as AiPostMessage & {
type?: string
payload?: unknown
text?: string
}
if (data?.type === "ULTI_DOCS_APPLY" && data.payload) {
handleApplyFromParent(data.payload)
}
if (data?.type === "ULTI_ASSISTANT_TEXT" && typeof data.text === "string") {
const cmd = extractDocsApplyFromMarkdown(data.text)
if (cmd) applyDocsAction(editor, cmd)
}
}
window.addEventListener("message", onMessage)
return () => window.removeEventListener("message", onMessage)
}, [open, editor, handleApplyFromParent])
useEffect(() => {
if (!open || !editor) return
;(window as Window & { __ultiDocsApply?: (p: unknown) => void }).__ultiDocsApply =
handleApplyFromParent
return () => {
delete (window as Window & { __ultiDocsApply?: (p: unknown) => void }).__ultiDocsApply
}
}, [open, editor, handleApplyFromParent])
const onResizePointerDown = (e: React.PointerEvent) => {
e.preventDefault()
resizeRef.current = { startX: e.clientX, startW: widthPx }
const onMove = (ev: PointerEvent) => {
const st = resizeRef.current
if (!st) return
const delta = st.startX - ev.clientX
setWidthPx(st.startW + delta)
}
const onUp = () => {
resizeRef.current = null
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
}
if (!config?.enabled || !open) return null
return (
<div
className="docs-ai-panel relative flex h-full shrink-0 flex-col border-l border-[#dadce0] bg-white dark:border-border dark:bg-background"
style={{ width: widthPx }}
data-docs-ai-panel
>
<button
type="button"
aria-label="Redimensionner le panneau UltiAI"
className="absolute left-0 top-0 z-10 flex h-full w-1.5 cursor-col-resize items-center justify-center hover:bg-[#e8eaed] dark:hover:bg-muted"
onPointerDown={onResizePointerDown}
>
<GripVertical className="pointer-events-none h-4 w-4 text-muted-foreground opacity-0 hover:opacity-100" />
</button>
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
<div className="flex min-w-0 items-center gap-2 text-sm font-medium">
<Sparkles className="h-4 w-4 shrink-0 text-[#1a73e8]" />
<span className="truncate">UltiAI</span>
</div>
<div className="flex shrink-0 items-center gap-1">
{quota ? (
<span className="text-[10px] text-muted-foreground">
{quota.requests_remaining} req.
</span>
) : null}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={closePanel}
aria-label="Fermer UltiAI"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{!editable ? (
<p className="px-3 py-2 text-xs text-muted-foreground">
Mode lecture l&apos;IA peut analyser le document mais pas le modifier.
</p>
) : null}
<div className="min-h-0 flex-1">
<AiChatIframe
publicPath={config.public_path}
context={{
...context,
systemPromptExtra: docsSystemPromptExtra(context),
}}
className={cn("h-full w-full border-0")}
/>
</div>
</div>
)
}
export function DocsAiPanelToggle({
disabled,
className,
}: {
disabled?: boolean
className?: string
}) {
const open = useDocsAiPanelStore((s) => s.open)
const toggle = useDocsAiPanelStore((s) => s.toggle)
const { data: config } = useAiConfig()
if (!config?.enabled) return null
return (
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"size-9 shrink-0 rounded-full",
open && "bg-[#e8f0fe] text-[#1a73e8] dark:bg-muted",
className
)}
disabled={disabled}
onClick={toggle}
aria-label="UltiAI"
title="UltiAI"
>
<Sparkles className="size-4" />
</Button>
)
}

View File

@ -14,11 +14,12 @@ import {
useSessionGuardStore,
} from "@/lib/auth/session-guard-store"
const PUBLIC_PREFIXES = ["/login", "/auth/", "/api/auth/"]
const PUBLIC_PREFIXES = ["/login", "/auth/", "/api/auth/", "/demo/"]
const REFRESH_LEAD_MS = 5 * 60 * 1000
const REFRESH_CHECK_MS = 60 * 1000
function isPublicPath(pathname: string) {
if (pathname === "/") return true
if (pathname.startsWith("/drive/s/")) return true
return PUBLIC_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(prefix)

View File

@ -0,0 +1,51 @@
"use client"
import { useEffect, useMemo } from "react"
import { RichTextDocumentEditor } from "@/components/drive/richtext-document"
import type { RichTextSessionResponse } from "@/lib/drive/richtext-types"
const DEMO_SESSION: RichTextSessionResponse = {
roomId: "demo-docs",
canonicalPath: "/Démo/Bienvenue dans UltiDocs.ultidoc.json",
wsUrl: "",
token: "",
mode: "edit",
importRequired: false,
collaboration: false,
documentUrl: "/demo/ultidoc-sample.json",
saveUrl: "/api/demo/richtext-save",
}
/** Éditeur UltiDocs réel, sans backend : contenu seedé, sauvegarde no-op (zéro rétention). */
export function DemoDocsEditor() {
useEffect(() => {
document.documentElement.dataset.routeScope = "drive"
}, [])
const chrome = useMemo(
() => ({
title: "Bienvenue dans UltiDocs",
showBack: false,
showShare: false,
showAccount: false,
trailing: (
<span className="rounded-full border border-[var(--mail-border)] px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
Démo zéro rétention
</span>
),
}),
[]
)
return (
<div className="flex h-dvh flex-col overflow-hidden">
<RichTextDocumentEditor
session={DEMO_SESSION}
mode="edit"
userName="Visiteur"
userColor="#4f6df5"
chrome={chrome}
/>
</div>
)
}

View File

@ -0,0 +1,496 @@
"use client"
import { useMemo, useState } from "react"
import {
Archive,
ArrowLeft,
Inbox,
Pencil,
RotateCcw,
Search,
Send,
Star,
Trash2,
X,
} from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import {
DEMO_EMAILS,
DEMO_USER,
type DemoEmail,
type DemoFolder,
} from "@/components/demo/demo-mail-data"
import { cn } from "@/lib/utils"
const FOLDERS: { id: DemoFolder; label: string; icon: typeof Inbox }[] = [
{ id: "inbox", label: "Boîte de réception", icon: Inbox },
{ id: "starred", label: "Favoris", icon: Star },
{ id: "sent", label: "Envoyés", icon: Send },
{ id: "archive", label: "Archive", icon: Archive },
{ id: "trash", label: "Corbeille", icon: Trash2 },
]
function demoToast(message: string) {
toast.message(message, {
description: "Mode démo : rien n'est envoyé ni conservé.",
})
}
function Avatar({ name, className }: { name: string; className?: string }) {
return (
<span
className={cn(
"flex shrink-0 items-center justify-center rounded-full text-sm font-medium text-white",
className ?? "size-9"
)}
style={{ backgroundColor: avatarColor(name) }}
aria-hidden
>
{senderInitial(name)}
</span>
)
}
function ComposeModal({
onClose,
onSend,
}: {
onClose: () => void
onSend: (email: { to: string; subject: string; body: string }) => void
}) {
const [to, setTo] = useState("")
const [subject, setSubject] = useState("")
const [body, setBody] = useState("")
return (
<div className="absolute bottom-0 right-0 z-30 flex w-full max-w-md flex-col overflow-hidden rounded-t-xl border border-[var(--mail-border)] bg-[var(--mail-surface-elevated)] shadow-2xl sm:bottom-4 sm:right-4 sm:rounded-xl">
<div className="flex items-center justify-between bg-[var(--mail-surface-muted)] px-4 py-2.5">
<span className="text-sm font-medium text-[var(--mail-text-strong)]">
Nouveau message
</span>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-[var(--mail-text-muted)] hover:bg-[var(--mail-hover)]"
aria-label="Fermer"
>
<X className="size-4" />
</button>
</div>
<div className="flex flex-col divide-y divide-[var(--mail-border-subtle)] px-4">
<input
value={to}
onChange={(e) => setTo(e.target.value)}
placeholder="À"
className="bg-transparent py-2.5 text-sm text-[var(--mail-text)] outline-none placeholder:text-[var(--mail-text-muted)]"
/>
<input
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Objet"
className="bg-transparent py-2.5 text-sm text-[var(--mail-text)] outline-none placeholder:text-[var(--mail-text-muted)]"
/>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Votre message…"
rows={7}
className="resize-none bg-transparent py-2.5 text-sm leading-relaxed text-[var(--mail-text)] outline-none placeholder:text-[var(--mail-text-muted)]"
/>
</div>
<div className="flex items-center justify-between px-4 py-3">
<Button
size="sm"
className="rounded-full px-5"
onClick={() => {
onSend({ to, subject, body })
onClose()
}}
>
Envoyer
</Button>
<span className="text-[11px] text-[var(--mail-text-muted)]">
Démo aucun envoi réel
</span>
</div>
</div>
)
}
export function DemoMailApp() {
const [emails, setEmails] = useState<DemoEmail[]>(DEMO_EMAILS)
const [folder, setFolder] = useState<DemoFolder>("inbox")
const [selectedId, setSelectedId] = useState<string | null>(null)
const [query, setQuery] = useState("")
const [composeOpen, setComposeOpen] = useState(false)
const [replyDraft, setReplyDraft] = useState("")
const visible = useMemo(() => {
const inFolder =
folder === "starred"
? emails.filter((e) => e.starred && e.folder !== "trash")
: emails.filter((e) => e.folder === folder)
const q = query.trim().toLowerCase()
if (!q) return inFolder
return inFolder.filter((e) =>
[e.fromName, e.fromEmail, e.subject, e.preview].some((field) =>
field.toLowerCase().includes(q)
)
)
}, [emails, folder, query])
const selected = emails.find((e) => e.id === selectedId) ?? null
const unreadCount = emails.filter(
(e) => e.folder === "inbox" && e.unread
).length
const patchEmail = (id: string, patch: Partial<DemoEmail>) =>
setEmails((prev) => prev.map((e) => (e.id === id ? { ...e, ...patch } : e)))
const openEmail = (email: DemoEmail) => {
setSelectedId(email.id)
setReplyDraft("")
if (email.unread) patchEmail(email.id, { unread: false })
}
const moveEmail = (id: string, dest: DemoEmail["folder"], message: string) => {
patchEmail(id, { folder: dest })
if (selectedId === id) setSelectedId(null)
demoToast(message)
}
const sendCompose = (draft: { to: string; subject: string; body: string }) => {
setEmails((prev) => [
{
id: `sent-${Date.now()}`,
fromName: DEMO_USER.name,
fromEmail: DEMO_USER.email,
subject: draft.subject || "(sans objet)",
preview: draft.body.slice(0, 110) || "(message vide)",
body: draft.body ? draft.body.split("\n\n") : ["(message vide)"],
time: "À l'instant",
unread: false,
starred: false,
folder: "sent",
},
...prev,
])
demoToast("Message « envoyé »")
}
return (
<div className="relative flex h-dvh flex-col overflow-hidden bg-[var(--app-canvas)] text-[var(--mail-text)]">
{/* Barre supérieure */}
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-[var(--mail-border-subtle)] bg-[var(--mail-surface)] px-3 sm:px-4">
<img
src="/brand/ultimail-header-icon.png"
alt=""
className="h-7 w-7 shrink-0 object-contain"
aria-hidden
/>
<span className="hidden text-lg font-semibold text-[var(--mail-text-strong)] sm:block">
Ultimail
</span>
<span className="rounded-full bg-[var(--mail-active)] px-2.5 py-0.5 text-[11px] font-semibold text-[var(--mail-nav-selected-fg)]">
Démo
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 rounded-full bg-[var(--mail-surface-muted)] px-3.5 py-2 sm:mx-4">
<Search className="size-4 shrink-0 text-[var(--mail-text-muted)]" aria-hidden />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Rechercher dans les messages"
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-[var(--mail-text-muted)]"
aria-label="Rechercher"
/>
</div>
<span
className="hidden items-center gap-1.5 rounded-full border border-[var(--mail-border)] px-2.5 py-1 text-[11px] font-medium text-[var(--mail-text-muted)] md:inline-flex"
title="Vos actions restent dans cet onglet et disparaissent au rechargement."
>
<RotateCcw className="size-3" aria-hidden />
Zéro rétention
</span>
<Avatar name={DEMO_USER.name} className="size-8 text-xs" />
</header>
<div className="flex min-h-0 flex-1">
{/* Barre latérale */}
<aside className="hidden w-56 shrink-0 flex-col gap-1 px-3 py-4 sm:flex">
<Button
className="mb-3 h-12 w-fit rounded-2xl px-5 shadow-sm"
onClick={() => setComposeOpen(true)}
>
<Pencil className="size-4" aria-hidden />
Nouveau message
</Button>
{FOLDERS.map((f) => {
const FolderIcon = f.icon
const active = folder === f.id
return (
<button
key={f.id}
type="button"
onClick={() => {
setFolder(f.id)
setSelectedId(null)
}}
className={cn(
"flex items-center justify-between rounded-full px-4 py-1.5 text-sm transition-colors",
active
? "bg-[var(--mail-nav-selected)] font-semibold text-[var(--mail-nav-selected-fg)]"
: "hover:bg-[var(--mail-nav-hover)]"
)}
>
<span className="flex items-center gap-3">
<FolderIcon className="size-4" aria-hidden />
{f.label}
</span>
{f.id === "inbox" && unreadCount > 0 ? (
<span className="text-xs font-semibold">{unreadCount}</span>
) : null}
</button>
)
})}
</aside>
{/* Contenu */}
<main className="m-0 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-[var(--mail-surface)] sm:mb-3 sm:mr-3 sm:rounded-2xl sm:border sm:border-[var(--mail-border-subtle)]">
{selected ? (
<article className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 items-center gap-1 border-b border-[var(--mail-border-subtle)] px-2 py-2 sm:px-4">
<Button
variant="ghost"
size="icon"
onClick={() => setSelectedId(null)}
aria-label="Retour à la liste"
>
<ArrowLeft className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() =>
moveEmail(selected.id, "archive", "Message archivé")
}
aria-label="Archiver"
>
<Archive className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() =>
moveEmail(selected.id, "trash", "Message placé dans la corbeille")
}
aria-label="Supprimer"
>
<Trash2 className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => patchEmail(selected.id, { starred: !selected.starred })}
aria-label={selected.starred ? "Retirer des favoris" : "Ajouter aux favoris"}
>
<Star
className={cn(
"size-4",
selected.starred && "fill-amber-400 text-amber-400"
)}
/>
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-8">
<h1 className="text-xl font-normal text-[var(--mail-text-strong)] sm:text-2xl">
{selected.subject}
</h1>
<div className="mt-5 flex items-center gap-3">
<Avatar name={selected.fromName} />
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[var(--mail-text-strong)]">
{selected.fromName}
<span className="ml-2 font-normal text-[var(--mail-text-muted)]">
&lt;{selected.fromEmail}&gt;
</span>
</p>
<p className="text-xs text-[var(--mail-text-muted)]">
À moi · {selected.time}
</p>
</div>
</div>
<div className="mt-6 max-w-2xl space-y-4 text-[15px] leading-relaxed">
{selected.body.map((paragraph, i) => (
<p key={i} className="whitespace-pre-line">
{paragraph}
</p>
))}
</div>
<div className="mt-8 max-w-2xl rounded-2xl border border-[var(--mail-border)] p-4">
<textarea
value={replyDraft}
onChange={(e) => setReplyDraft(e.target.value)}
placeholder={`Répondre à ${selected.fromName}`}
rows={3}
className="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-[var(--mail-text-muted)]"
/>
<div className="mt-2 flex items-center justify-between">
<Button
size="sm"
className="rounded-full px-5"
onClick={() => {
setReplyDraft("")
demoToast("Réponse « envoyée »")
}}
>
Envoyer
</Button>
<span className="text-[11px] text-[var(--mail-text-muted)]">
Démo aucun envoi réel
</span>
</div>
</div>
</div>
</article>
) : (
<div className="min-h-0 flex-1 overflow-y-auto">
{visible.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
<Inbox className="size-8 text-[var(--mail-text-muted)]" aria-hidden />
<p className="text-sm text-[var(--mail-text-muted)]">
{query
? "Aucun message ne correspond à votre recherche."
: "Ce dossier est vide."}
</p>
</div>
) : (
<ul className="divide-y divide-[var(--mail-list-divider)]">
{visible.map((email) => (
<li key={email.id}>
<div
role="button"
tabIndex={0}
onClick={() => openEmail(email)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openEmail(email)
}}
className={cn(
"group flex w-full cursor-pointer items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-[var(--mail-hover)] sm:px-4",
email.unread
? "bg-[var(--mail-row-unread)]"
: "bg-[var(--mail-row-read)]"
)}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
patchEmail(email.id, { starred: !email.starred })
}}
className="shrink-0 rounded p-1 text-[var(--mail-text-muted)] hover:text-amber-500"
aria-label={
email.starred ? "Retirer des favoris" : "Ajouter aux favoris"
}
>
<Star
className={cn(
"size-4",
email.starred && "fill-amber-400 text-amber-400"
)}
/>
</button>
<Avatar name={email.fromName} className="hidden size-8 text-xs sm:flex" />
<span
className={cn(
"w-32 shrink-0 truncate text-sm sm:w-44",
email.unread
? "font-semibold text-[var(--mail-text-strong)]"
: "text-[var(--mail-text)]"
)}
>
{email.fromName}
</span>
<span className="min-w-0 flex-1 truncate text-sm">
{email.label ? (
<span
className="mr-2 rounded px-1.5 py-px text-[10px] font-semibold text-white"
style={{ backgroundColor: email.label.color }}
>
{email.label.text}
</span>
) : null}
<span
className={cn(
email.unread
? "font-semibold text-[var(--mail-text-strong)]"
: "text-[var(--mail-text)]"
)}
>
{email.subject}
</span>
<span className="text-[var(--mail-text-muted)]">
{" "}
{email.preview}
</span>
</span>
<span className="hidden shrink-0 items-center gap-1 group-hover:flex">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
moveEmail(email.id, "archive", "Message archivé")
}}
className="rounded p-1.5 text-[var(--mail-text-muted)] hover:bg-[var(--mail-surface-muted)]"
aria-label="Archiver"
>
<Archive className="size-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
moveEmail(email.id, "trash", "Message placé dans la corbeille")
}}
className="rounded p-1.5 text-[var(--mail-text-muted)] hover:bg-[var(--mail-surface-muted)]"
aria-label="Supprimer"
>
<Trash2 className="size-4" />
</button>
</span>
<span
className={cn(
"shrink-0 text-xs group-hover:hidden",
email.unread
? "font-semibold text-[var(--mail-text-strong)]"
: "text-[var(--mail-text-muted)]"
)}
>
{email.time}
</span>
</div>
</li>
))}
</ul>
)}
</div>
)}
</main>
</div>
{/* Bouton composer (mobile) */}
<Button
className="absolute bottom-5 right-5 z-20 size-14 rounded-2xl shadow-lg sm:hidden"
onClick={() => setComposeOpen(true)}
aria-label="Nouveau message"
>
<Pencil className="size-5" />
</Button>
{composeOpen ? (
<ComposeModal onClose={() => setComposeOpen(false)} onSend={sendCompose} />
) : null}
</div>
)
}

View File

@ -0,0 +1,151 @@
export type DemoFolder = "inbox" | "starred" | "sent" | "archive" | "trash"
export type DemoEmail = {
id: string
fromName: string
fromEmail: string
subject: string
preview: string
/** Paragraphes du corps du message. */
body: string[]
time: string
unread: boolean
starred: boolean
folder: Exclude<DemoFolder, "starred">
label?: { text: string; color: string }
}
export const DEMO_USER = {
name: "Camille Visiteur",
email: "camille@demo.ulti",
}
export const DEMO_EMAILS: DemoEmail[] = [
{
id: "m1",
fromName: "Léa Fontaine",
fromEmail: "lea.fontaine@atelier-nord.fr",
subject: "Compte rendu — comité produit du 9 juin",
preview:
"Bonjour Camille, voici le compte rendu de notre comité produit. Les décisions clés sont en gras…",
body: [
"Bonjour Camille,",
"Voici le compte rendu de notre comité produit de mardi. Les décisions clés : lancement de la version 2.4 au 1er juillet, gel des nouvelles fonctionnalités à partir du 20 juin, et revue sécurité planifiée la semaine prochaine.",
"Le document complet est partagé dans UltiDrive (dossier Produit > Comités). N'hésite pas à commenter directement dedans, la co-édition est active.",
"Bonne journée,\nLéa",
],
time: "09:42",
unread: true,
starred: false,
folder: "inbox",
label: { text: "Produit", color: "#4f6df5" },
},
{
id: "m2",
fromName: "UltiAI",
fromEmail: "assistant@demo.ulti",
subject: "Votre résumé du matin — 7 mails traités",
preview:
"Pendant la nuit : 2 mails urgents détectés, 4 newsletters archivées automatiquement, 1 facture classée…",
body: [
"Bonjour Camille,",
"Voici ce que j'ai préparé pour vous ce matin :",
"• 2 mails marqués urgents (réponse attendue avant midi)\n• 4 newsletters archivées selon vos règles\n• 1 facture détectée et classée dans Comptabilité\n• 1 invitation agenda en attente de réponse",
"Astuce : vous pouvez ajuster mes règles de tri dans Réglages > Tri IA.",
],
time: "08:15",
unread: true,
starred: true,
folder: "inbox",
label: { text: "IA", color: "#9a5cf0" },
},
{
id: "m3",
fromName: "Marc Delcourt",
fromEmail: "marc@delcourt-conseil.com",
subject: "Re: Proposition de partenariat — version finale",
preview:
"Parfait pour moi. J'ai signé la dernière version dans le document partagé, on peut avancer…",
body: [
"Bonjour,",
"Parfait pour moi. J'ai relu la dernière version dans le document partagé et tout est bon — on peut avancer sur la signature cette semaine.",
"Merci pour la réactivité !",
"Marc",
],
time: "Hier",
unread: false,
starred: true,
folder: "inbox",
},
{
id: "m4",
fromName: "Notifications UltiDrive",
fromEmail: "drive@demo.ulti",
subject: "Sarah a commenté « Budget 2026.ultidoc »",
preview:
"« Je propose qu'on revoie la ligne infrastructure à la hausse, le trafic a doublé depuis janvier »…",
body: [
"Sarah Lemoine a ajouté un commentaire sur le document Budget 2026 :",
"« Je propose qu'on revoie la ligne infrastructure à la hausse, le trafic a doublé depuis janvier. »",
"Ouvrez le document pour répondre ou résoudre le commentaire.",
],
time: "Hier",
unread: false,
starred: false,
folder: "inbox",
label: { text: "Drive", color: "#1fb6c9" },
},
{
id: "m5",
fromName: "Anaïs Rivet",
fromEmail: "anais.rivet@coop-numerique.org",
subject: "Migration terminée 🎉 — retour d'expérience",
preview:
"On a finalisé la migration des 40 comptes la semaine dernière. Bilan : aucune perte, équipe ravie…",
body: [
"Salut Camille,",
"On a finalisé la migration des 40 comptes la semaine dernière. Bilan : aucune perte de données, l'équipe a retrouvé ses habitudes en une journée grâce à l'interface familière.",
"Je te prépare un retour d'expérience complet pour le blog si ça t'intéresse.",
"Anaïs",
],
time: "Lun.",
unread: false,
starred: false,
folder: "inbox",
},
{
id: "m6",
fromName: "Camille Visiteur",
fromEmail: "camille@demo.ulti",
subject: "Ordre du jour — réunion d'équipe jeudi",
preview:
"Bonjour à tous, voici l'ordre du jour pour jeudi : avancement sprint, démo des nouveautés, points bloquants…",
body: [
"Bonjour à tous,",
"Voici l'ordre du jour pour la réunion de jeudi 10h :",
"1. Avancement du sprint en cours\n2. Démo des nouveautés UltiDocs\n3. Points bloquants et arbitrages\n4. Divers",
"À jeudi !",
],
time: "Lun.",
unread: false,
starred: false,
folder: "sent",
},
{
id: "m7",
fromName: "Infra Ulti",
fromEmail: "infra@demo.ulti",
subject: "Sauvegarde hebdomadaire effectuée ✓",
preview:
"La sauvegarde chiffrée de votre instance s'est terminée sans erreur (durée : 4 min 12 s)…",
body: [
"La sauvegarde chiffrée de votre instance s'est terminée sans erreur.",
"Durée : 4 min 12 s — 18,4 Go — intégrité vérifiée.",
"Prochaine sauvegarde planifiée : dimanche 03:00.",
],
time: "Dim.",
unread: false,
starred: false,
folder: "archive",
},
]

View File

@ -2,6 +2,7 @@
import { useEffect, useLayoutEffect, type ReactNode } from "react"
import { DriveSidebar } from "@/components/drive/drive-sidebar"
import { AiChatPanel } from "@/components/ai/ai-chat-panel"
import { FilePreviewDialog } from "@/components/drive/file-preview-dialog"
import { ShareDialog } from "@/components/drive/share-dialog"
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
@ -37,6 +38,7 @@ export function DriveAppShell({ children }: { children: ReactNode }) {
<div className="flex min-w-0 flex-1 flex-col bg-app-canvas" data-drive-main-column>{children}</div>
<ShareDialog />
<FilePreviewDialog />
<AiChatPanel />
</div>
</SuiteThemeShell>
)

View File

@ -6,6 +6,7 @@ import {
FileText,
FolderPlus,
FolderUp,
PenLine,
Presentation,
Upload,
} from "lucide-react"
@ -125,6 +126,11 @@ export function DriveNewSheet({
label="Présentation"
onClick={() => pick("presentation")}
/>
<SheetAction
icon={<PenLine className="text-violet-600" />}
label="Dessin"
onClick={() => pick("drawing")}
/>
<SheetAction
icon={<FolderPlus className="text-amber-500" />}
label="Dossier"

View File

@ -6,6 +6,7 @@ import {
FileText,
FolderPlus,
FolderUp,
PenLine,
Plus,
Presentation,
Upload,
@ -94,6 +95,13 @@ export function DriveNewMenu({ parentPath }: { parentPath: string }) {
<Presentation className="text-amber-600" />
Présentation
</DropdownMenuItem>
<DropdownMenuItem
className={DRIVE_NEW_MENU_ITEM_CLASS}
onClick={() => pickKind("drawing")}
>
<PenLine className="text-violet-600" />
Dessin
</DropdownMenuItem>
<DropdownMenuItem className={DRIVE_NEW_MENU_ITEM_CLASS} onClick={() => pickKind("folder")}>
<FolderPlus className="text-amber-500" />
Dossier

View File

@ -3,8 +3,12 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { ArrowLeft, Loader2 } from "lucide-react"
import { ArrowLeft } from "lucide-react"
import { RichTextDocumentEditor } from "@/components/drive/richtext-document"
import {
DocsEditorLoadingShell,
useDocsEditorLoadingState,
} from "@/components/drive/richtext/docs-editor-loading-shell"
import { displayFileBaseName } from "@/lib/drive/display-file-name"
import { resolvePublicShareEditReturnTo, shouldShowPublicShareEditorBack } from "@/lib/drive/public-share-url"
import type { PublicShareRootType } from "@/lib/drive/public-share-url"
@ -141,6 +145,10 @@ export function PublicRichTextEditor({
[title, backHref, showBack, resolvedMode, guest.color, guest.guestName]
)
const { documentLoading, documentPhase, onDocumentLoadingChange } = useDocsEditorLoadingState(
session?.roomId ?? filePath
)
if (error) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8">
@ -158,12 +166,14 @@ export function PublicRichTextEditor({
}
return (
<div className="flex h-dvh min-h-0 flex-col">
{!session ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<DocsEditorLoadingShell
title={title}
resolvingFile={false}
awaitingSession={!session}
documentLoading={Boolean(session) && documentLoading}
documentPhase={documentPhase}
>
{session ? (
<RichTextDocumentEditor
session={session}
mode={resolvedMode}
@ -172,8 +182,10 @@ export function PublicRichTextEditor({
fetchSourceBytes={fetchSourceBytes}
importApi={importApi}
chrome={chrome}
deferSplash
onLoadingChange={onDocumentLoadingChange}
/>
)}
</div>
) : null}
</DocsEditorLoadingShell>
)
}

View File

@ -5,6 +5,7 @@ import Link from "next/link"
import { useRouter } from "next/navigation"
import { useEffect, useState, type ReactNode } from "react"
import { ChevronRight, Download, FolderOpen, Loader2, Lock } from "lucide-react"
import { DocsLoadingSplash } from "@/components/drive/richtext/docs-loading-splash"
import { toast } from "sonner"
import { PublicShareFolderView } from "@/components/drive/public-share-folder-view"
import { FilePreviewDialog } from "@/components/drive/file-preview-dialog"
@ -21,7 +22,11 @@ import type { DriveFileInfo } from "@/lib/api/types"
import { drivePreviewKind, isSvgFile } from "@/lib/drive/drive-preview"
import { SvgPreviewViewer } from "@/components/drive/svg-preview-viewer"
import { PUBLIC_SHARE_INSET_X } from "@/lib/drive/drive-chrome-classes"
import { shouldOpenInOnlyOffice, shouldOpenInRichTextEditor } from "@/lib/drive/drive-preview"
import {
shouldOpenInOnlyOffice,
shouldOpenInRichTextEditor,
shouldOpenInUltidrawEditor,
} from "@/lib/drive/drive-preview"
import {
sharePermCanEdit,
} from "@/lib/drive/drive-share-permissions"
@ -227,13 +232,20 @@ export function PublicShareViewPanel({
useEffect(() => {
if (!file) return
const isEditorFile = shouldOpenInRichTextEditor(file) || shouldOpenInOnlyOffice(file)
const isEditorFile =
shouldOpenInRichTextEditor(file) ||
shouldOpenInUltidrawEditor(file) ||
shouldOpenInOnlyOffice(file)
if (!isEditorFile) return
const returnTo =
typeof window !== "undefined"
? window.location.pathname + window.location.search
: undefined
const editor = shouldOpenInRichTextEditor(file) ? "richtext" : "office"
const editor = shouldOpenInUltidrawEditor(file)
? "ultidraw"
: shouldOpenInRichTextEditor(file)
? "richtext"
: "office"
router.replace(
buildPublicShareEditHref(
token,
@ -259,12 +271,13 @@ export function PublicShareViewPanel({
anchor.remove()
}
if (file && (shouldOpenInRichTextEditor(file) || shouldOpenInOnlyOffice(file))) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
if (
file &&
(shouldOpenInRichTextEditor(file) ||
shouldOpenInUltidrawEditor(file) ||
shouldOpenInOnlyOffice(file))
) {
return <DocsLoadingSplash phase="opening" title={file.name} />
}
return (

View File

@ -0,0 +1,172 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { ArrowLeft } from "lucide-react"
import { UltidrawDocumentEditor } from "@/components/drive/ultidraw-document"
import {
DocsEditorLoadingShell,
useDocsEditorLoadingState,
} from "@/components/drive/richtext/docs-editor-loading-shell"
import { displayFileBaseName } from "@/lib/drive/display-file-name"
import {
resolvePublicShareEditReturnTo,
shouldShowPublicShareEditorBack,
} from "@/lib/drive/public-share-url"
import type { PublicShareRootType } from "@/lib/drive/public-share-url"
import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title"
import { getGuestEditorIdentity } from "@/lib/drive/guest-editor-identity"
import type { UltidrawSessionResponse } from "@/lib/drive/ultidraw-types"
import { fetchPublicShareBlob } from "@/lib/api/public-share"
function fileNameFromPath(filePath: string, fallback?: string): string {
const base = filePath.split("/").filter(Boolean).pop()
return base || fallback || filePath
}
export function PublicUltidrawEditor({
token,
filePath,
password,
returnTo,
mode = "edit",
fileDisplayName,
shareRoot,
}: {
token: string
filePath: string
password?: string
returnTo?: string | null
mode?: "edit" | "view"
fileDisplayName?: string
shareRoot?: PublicShareRootType | null
}) {
const guest = useMemo(() => getGuestEditorIdentity(token), [token])
const [session, setSession] = useState<UltidrawSessionResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const [resolvedMode, setResolvedMode] = useState<"edit" | "view">(mode)
const fileName = fileDisplayName || fileNameFromPath(filePath)
const title = displayFileBaseName(fileName)
useDriveDocumentTitle(title)
const backHref = useMemo(
() => resolvePublicShareEditReturnTo(token, returnTo, filePath),
[token, returnTo, filePath]
)
const showBack = shouldShowPublicShareEditorBack(shareRoot, returnTo, filePath)
useEffect(() => {
let cancelled = false
setSession(null)
setError(null)
void (async () => {
try {
const res = await fetch(
`/api/v1/drive/public/shares/${encodeURIComponent(token)}/ultidraw/session`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: filePath,
mode,
password: password ?? "",
guest_id: guest.guestId,
guest_name: guest.guestName,
display_name: fileName,
}),
}
)
if (!res.ok) throw new Error("Session indisponible")
const data = (await res.json()) as UltidrawSessionResponse
if (!cancelled) {
setSession(data)
setResolvedMode(data.mode === "view" ? "view" : mode)
}
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : "Impossible d'ouvrir le dessin")
}
}
})()
return () => {
cancelled = true
}
}, [token, filePath, mode, password, guest.guestId, guest.guestName, fileName])
const fetchSource = useMemo(
() =>
async (path: string) => {
const blob = await fetchPublicShareBlob(
token,
{ path, name: path.split("/").pop() ?? path },
password
)
return await blob.text()
},
[token, password]
)
const { documentLoading, documentPhase, onDocumentLoadingChange } = useDocsEditorLoadingState(
session?.roomId ?? filePath
)
if (error) {
return (
<div className="flex h-dvh flex-col items-center justify-center gap-4 p-8">
<p className="text-sm text-muted-foreground">{error}</p>
{showBack ? (
<Button variant="outline" asChild>
<Link href={backHref}>
<ArrowLeft className="mr-2 h-4 w-4" />
Retour
</Link>
</Button>
) : null}
</div>
)
}
return (
<DocsEditorLoadingShell
title={title}
resolvingFile={false}
awaitingSession={!session}
documentLoading={Boolean(session) && documentLoading}
documentPhase={documentPhase}
>
{session ? (
<div className="flex h-full min-h-0 flex-col">
{showBack ? (
<div className="flex h-12 shrink-0 items-center border-b border-border px-3">
<Button variant="ghost" size="sm" asChild>
<Link href={backHref}>
<ArrowLeft className="mr-1 h-4 w-4" />
Retour
</Link>
</Button>
</div>
) : null}
<div className="min-h-0 flex-1">
<UltidrawDocumentEditor
session={session}
mode={resolvedMode}
userName={guest.guestName}
userColor={guest.color}
chrome={{
title,
showBack: false,
showShare: false,
showAccount: false,
}}
fetchDocument={fetchSource}
deferSplash
onLoadingChange={onDocumentLoadingChange}
/>
</div>
</div>
) : null}
</DocsEditorLoadingShell>
)
}

View File

@ -8,6 +8,7 @@ import * as Y from "yjs"
import { toast } from "sonner"
import { DocsChrome } from "@/components/drive/richtext/docs-chrome"
import { DocsEditorWorkspace } from "@/components/drive/richtext/docs-editor-workspace"
import { DocsLoadingSplash } from "@/components/drive/richtext/docs-loading-splash"
import { DocsStatusBar } from "@/components/drive/richtext/docs-page-view"
import { DocsToolbar } from "@/components/drive/richtext/docs-toolbar"
import { buildRichTextExtensions, RICHTEXT_EDITOR_CLASS } from "@/lib/drive/richtext-extensions"
@ -15,6 +16,8 @@ import type { RichTextSaveStatus, RichTextSessionResponse } from "@/lib/drive/ri
import type { DriveShare, DriveFileInfo } from "@/lib/api/types"
import { useDocsEditMenu } from "@/lib/drive/use-docs-edit-menu"
import { useDocsFileMenu } from "@/lib/drive/use-docs-file-menu"
import { useDocsFormatMenu } from "@/lib/drive/use-docs-format-menu"
import { useDocsInsertMenu } from "@/lib/drive/use-docs-insert-menu"
import type { DocsViewMenuActions, DocsViewMenuState } from "@/components/drive/richtext/docs-view-menu"
import { useDocsViewSettings } from "@/lib/drive/docs-view-settings"
import { useDocsKeyboardShortcutsStore } from "@/lib/stores/docs-keyboard-shortcuts-store"
@ -29,10 +32,21 @@ import {
type DocPageSetup,
} from "@/lib/drive/doc-page-setup"
import { readUserPageSetupDefaults } from "@/lib/drive/docs-page-defaults"
import { isEmptyTipTapDoc } from "@/lib/drive/richtext-content"
import { ensureMinimalTipTapDoc, isEmptyTipTapDoc } from "@/lib/drive/richtext-content"
import { importFileToTipTap } from "@/lib/drive/richtext-import"
import { migrateBase64ImagesInContent } from "@/lib/drive/docs-graphic-assets"
import { isUltidocPath } from "@/lib/drive/richtext-formats"
import { defaultDocumentParagraphStyles, type DocParagraphStylesCatalog } from "@/lib/drive/docs-paragraph-styles"
import { DocsParagraphStylesProvider } from "@/lib/drive/docs-paragraph-styles-context"
import { useDocsParagraphStyles } from "@/lib/drive/use-docs-paragraph-styles"
import {
resolveRichTextDocumentLoadingPhase,
type DocsLoadingPhase,
} from "@/lib/drive/docs-loading-phase"
import { buildDocsExportSnapshot } from "@/lib/drive/docs-export-snapshot"
import { printDocsDocument } from "@/lib/drive/docs-print"
import { DocsAiPanel, DocsAiPanelToggle } from "@/components/ai/docs-ai-panel"
import { useDocsAiPanelStore } from "@/lib/ai/use-docs-ai-panel"
import { cn } from "@/lib/utils"
const SAVE_DEBOUNCE_MS = 2000
@ -68,6 +82,8 @@ export function RichTextDocumentEditor({
fetchSourceBytes,
importApi,
chrome,
deferSplash = false,
onLoadingChange,
}: {
session: RichTextSessionResponse
mode: "edit" | "view"
@ -81,6 +97,8 @@ export function RichTextDocumentEditor({
pageSetup?: DocPageSetup | null
}) => Promise<void>
chrome?: RichTextDocsChromeProps
deferSplash?: boolean
onLoadingChange?: (loading: boolean, phase: DocsLoadingPhase) => void
}) {
const editable = mode === "edit"
const collaboration = session.collaboration && Boolean(session.wsUrl && session.token)
@ -96,6 +114,9 @@ export function RichTextDocumentEditor({
const [importDone, setImportDone] = useState(!session.importRequired)
const [contentImportPending, setContentImportPending] = useState(session.importRequired)
const [importedContent, setImportedContent] = useState<Record<string, unknown> | null>(null)
const [documentParagraphStyles, setDocumentParagraphStyles] = useState<DocParagraphStylesCatalog>(
() => session.paragraphStyles ?? defaultDocumentParagraphStyles()
)
const [documentPageSetup, setDocumentPageSetup] = useState<DocPageSetup | null>(
session.pageSetup ?? null
)
@ -127,6 +148,7 @@ export function RichTextDocumentEditor({
toggleShowNonPrintableChars,
} = useDocsViewSettings()
const shellRef = useRef<HTMLDivElement>(null)
const getPageStackElementRef = useRef<() => HTMLElement | null>(() => null)
const presenceUsers = useCollabPresence(provider, { name: userName, color: userColor })
const pageLayout = useMemo(
() => resolveDocumentPageLayout(documentPageSetup, settings.pageFormatId),
@ -377,12 +399,10 @@ export function RichTextDocumentEditor({
: await (await apiClient.getBlob(driveDownloadApiPath(source))).arrayBuffer()
const imported = await importFileToTipTap(source.split("/").pop() ?? "file.docx", buf)
if (cancelled) return
if (isEmptyTipTapDoc(imported.content as Record<string, unknown>)) {
throw new Error("Le fichier source n'a produit aucun contenu importable")
}
const content = ensureMinimalTipTapDoc(imported.content as Record<string, unknown>)
const payload = {
source_path: source,
content: imported.content,
content,
pageSetup: imported.pageSetup ?? undefined,
}
if (importApi) {
@ -397,7 +417,7 @@ export function RichTextDocumentEditor({
window.location.reload()
return
}
setImportedContent(imported.content as Record<string, unknown>)
setImportedContent(content)
setContentImportPending(false)
setImportDone(true)
purgeReimportingRef.current = false
@ -454,44 +474,55 @@ export function RichTextDocumentEditor({
}
}, [collaboration, importDone, session.roomId, session.token, session.wsUrl, ydoc])
const persistDocument = useCallback(
async (json: Record<string, unknown>) => {
let content = json
try {
content = await migrateBase64ImagesInContent(json, async ({ dataUrl }) => {
const res = await apiClient.post<{ assetId: string; url: string }>(
"/richtext/assets",
{ path: session.canonicalPath, dataUrl }
)
return { assetId: res.assetId, url: res.url }
})
} catch {
/* keep base64 fallback */
}
const doc = { schemaVersion: 1, editor: "tiptap", content }
const body = JSON.stringify(doc)
const savePromise = session.saveUrl
? fetch(session.saveUrl, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body,
}).then((res) => {
if (!res.ok) throw new Error("save failed")
})
: apiClient.put("/richtext/save", { path: session.canonicalPath, document: content })
await savePromise
},
[session.canonicalPath, session.saveUrl]
)
const scheduleSave = useCallback(
(json: Record<string, unknown>) => {
(json: Record<string, unknown>, options?: { immediate?: boolean }) => {
if (!editable || collaboration) return
if (saveTimer.current) clearTimeout(saveTimer.current)
if (saveStatusRef.current !== "saving") {
reportSaveStatus("saving")
}
saveTimer.current = setTimeout(() => {
void (async () => {
let content = json
try {
content = await migrateBase64ImagesInContent(json, async ({ dataUrl }) => {
const res = await apiClient.post<{ assetId: string; url: string }>(
"/richtext/assets",
{ path: session.canonicalPath, dataUrl }
)
return { assetId: res.assetId, url: res.url }
})
} catch {
/* keep base64 fallback */
}
const doc = { schemaVersion: 1, editor: "tiptap", content }
const body = JSON.stringify(doc)
const savePromise = session.saveUrl
? fetch(session.saveUrl, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body,
}).then((res) => {
if (!res.ok) throw new Error("save failed")
})
: apiClient.put("/richtext/save", { path: session.canonicalPath, document: content })
await savePromise
reportSaveStatus("saved")
})().catch(() => reportSaveStatus("error"))
}, SAVE_DEBOUNCE_MS)
const runSave = () => {
void persistDocument(json)
.then(() => reportSaveStatus("saved"))
.catch(() => reportSaveStatus("error"))
}
if (options?.immediate) {
runSave()
return
}
saveTimer.current = setTimeout(runSave, SAVE_DEBOUNCE_MS)
},
[collaboration, editable, reportSaveStatus, session.canonicalPath, session.saveUrl]
[collaboration, editable, persistDocument, reportSaveStatus]
)
const collabReady = !collaboration || (Boolean(provider) && collabSynced)
@ -531,6 +562,26 @@ export function RichTextDocumentEditor({
[editorEnabled, extensions, collaboration, markCollabDirty, scheduleSave]
)
useEffect(() => {
if (!editor || editor.isDestroyed) return
const flushAfterDrawSave = () => {
if (!editable) return
const json = editor.getJSON() as Record<string, unknown>
if (collaboration) {
if (saveStatusRef.current !== "saving") {
reportSaveStatus("saving")
}
void persistDocument(json)
.then(() => reportSaveStatus("saved"))
.catch(() => reportSaveStatus("error"))
return
}
scheduleSave(json, { immediate: true })
}
window.addEventListener("ultidocs:graphic-draw-saved", flushAfterDrawSave)
return () => window.removeEventListener("ultidocs:graphic-draw-saved", flushAfterDrawSave)
}, [collaboration, editable, editor, persistDocument, reportSaveStatus, scheduleSave])
useEffect(() => {
if (!editor || editor.isDestroyed) return
@ -556,11 +607,86 @@ export function RichTextDocumentEditor({
}
}, [editor, settings.spellcheck])
const editMenu = useDocsEditMenu({
editor,
disabled: !editable,
})
const insertMenu = useDocsInsertMenu({
editor,
disabled: !editable,
pageSetup: documentPageSetup,
onPageSetupPatch: handlePageSetupPatch,
})
const paragraphStyles = useDocsParagraphStyles({
editor,
initialDocumentStyles: documentParagraphStyles,
editable,
canonicalPath: session.canonicalPath,
saveUrl: session.saveUrl,
})
useEffect(() => {
setDocumentParagraphStyles(paragraphStyles.state.documentStyles)
}, [paragraphStyles.state.documentStyles])
useEffect(() => {
if (session.paragraphStyles) setDocumentParagraphStyles(session.paragraphStyles)
}, [session.paragraphStyles])
const paragraphStylesContextValue = useMemo(
() => ({
state: paragraphStyles.state,
applyStyle: paragraphStyles.applyStyle,
updateStyleFromSelection: paragraphStyles.updateStyleFromSelection,
createUserStyle: paragraphStyles.createUserStyle,
updateDocumentStyle: paragraphStyles.updateDocumentStyle,
}),
[paragraphStyles]
)
const getExportSnapshot = useCallback(() => {
if (!editor || !chrome?.file) return null
return buildDocsExportSnapshot({
editor,
sourceName: chrome.file.name,
title: chrome.title,
pageSetup: documentPageSetup,
fallbackFormatId: settings.pageFormatId,
paragraphStyles: paragraphStyles.state.mergedCatalog,
pageCount,
getPageStackElement: () => getPageStackElementRef.current(),
})
}, [
chrome?.file,
chrome?.title,
documentPageSetup,
editor,
pageCount,
paragraphStyles.state.mergedCatalog,
settings.pageFormatId,
])
const handlePrintDocument = useCallback(async () => {
const snapshot = getExportSnapshot()
if (!snapshot) {
window.print()
return
}
try {
await printDocsDocument(snapshot)
} catch {
toast.error("Impossible d'imprimer le document")
}
}, [getExportSnapshot])
const fileMenu = useDocsFileMenu({
file: chrome?.file,
editor,
pageSetup: documentPageSetup,
fallbackFormatId: settings.pageFormatId,
getExportSnapshot,
onPageSetupApply: (setup) => {
documentPageSetupRef.current = setup
schedulePageSetupPatch(setup, { immediate: true })
@ -572,9 +698,10 @@ export function RichTextDocumentEditor({
disabled: !editable,
})
const editMenu = useDocsEditMenu({
const formatMenu = useDocsFormatMenu({
editor,
disabled: !editable,
onPageSetup: fileMenu.actions.onPageSetup,
})
const handleFullscreen = useCallback(() => {
@ -622,15 +749,34 @@ export function RichTextDocumentEditor({
]
)
const closeDocsAiPanel = useDocsAiPanelStore((s) => s.closePanel)
useEffect(() => {
closeDocsAiPanel()
}, [session.canonicalPath, closeDocsAiPanel])
const chromeProps = chrome
? {
...chrome,
trailing: (
<>
{chrome.trailing}
<DocsAiPanelToggle />
</>
),
fileMenuActions: fileMenu.actions,
fileMenuDialogs: fileMenu.dialogs,
fileMenuDisabled: fileMenu.disabled,
editMenuActions: editMenu.actions,
editMenuState: editMenu.state,
editMenuDisabled: editMenu.disabled,
insertMenuActions: insertMenu.actions,
insertMenuDialogs: insertMenu.dialogs,
insertMenuDisabled: insertMenu.disabled,
insertMenuPageElementsEnabled: insertMenu.pageElementsEnabled,
formatMenuActions: formatMenu.actions,
formatMenuState: formatMenu.state,
formatMenuDisabled: formatMenu.disabled,
viewMenuActions,
viewMenuState,
viewMenuDisabled: false,
@ -716,6 +862,20 @@ export function RichTextDocumentEditor({
settings.pageFormatId,
])
const documentLoading = !editorEnabled || !editor
const loadingPhase = resolveRichTextDocumentLoadingPhase({
contentImportPending,
importDone,
importRequired: session.importRequired,
collaboration: Boolean(collaboration),
collabSynced,
})
useEffect(() => {
if (!deferSplash) return
onLoadingChange?.(documentLoading, loadingPhase)
}, [deferSplash, documentLoading, loadingPhase, onLoadingChange])
if (collabError) {
return (
<div className="flex h-full items-center justify-center p-6 text-sm text-destructive">
@ -724,32 +884,25 @@ export function RichTextDocumentEditor({
)
}
if (!editorEnabled || !editor) {
const statusText =
contentImportPending && !importDone
? "Import du document…"
: session.importRequired && !importDone
? "Import du document…"
: collaboration && !collabSynced
? "Connexion à la collaboration…"
: "Connexion…"
if (!deferSplash && documentLoading) {
return (
<div className="flex h-full flex-col">
{chromeProps && !settings.chromeCollapsed ? (
<DocsChrome
{...chromeProps}
saveStatus={saveStatus}
presenceUsers={presenceUsers}
/>
) : null}
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{statusText}
</div>
</div>
<DocsLoadingSplash
phase={loadingPhase}
title={chromeProps?.title}
/>
)
}
if (deferSplash && documentLoading) {
return null
}
if (!editor) {
return null
}
return (
<DocsParagraphStylesProvider value={paragraphStylesContextValue}>
<div
ref={shellRef}
className={cn(
@ -765,41 +918,54 @@ export function RichTextDocumentEditor({
/>
) : null}
{chrome ? (
<DocsEditorWorkspace
editor={editor}
pageLayout={pageLayout}
zoom={settings.zoom}
editable={editable && settings.editorMode !== "view"}
showLayout={settings.showLayout}
showRuler={settings.showRuler}
showNonPrintableChars={settings.showNonPrintableChars}
editorMode={settings.editorMode}
outlineExpanded={settings.outlineSidebarExpanded}
onToggleOutline={toggleOutlineSidebarExpanded}
onPageCountChange={handlePageCountChange}
onCurrentPageChange={handleCurrentPageChange}
onRegionContentChange={handleRegionContentChange}
onPageSetupChange={handlePageSetupPatch}
onRegionEditorChange={setRegionEditor}
toolbarShellClassName={
settings.chromeCollapsed ? "docs-toolbar-shell--collapsed" : undefined
}
toolbar={
editable ? (
<DocsToolbar
editor={regionEditor ?? editor}
zoom={settings.zoom}
onZoomChange={setZoom}
spellcheck={settings.spellcheck}
onToggleSpellcheck={toggleSpellcheck}
showChromeToggle={Boolean(chrome)}
chromeCollapsed={settings.chromeCollapsed}
onToggleChromeCollapsed={toggleChromeCollapsed}
embedded
/>
) : null
}
/>
<div className="flex min-h-0 flex-1 flex-row">
<DocsEditorWorkspace
editor={editor}
pageLayout={pageLayout}
zoom={settings.zoom}
editable={editable && settings.editorMode !== "view"}
showLayout={settings.showLayout}
showRuler={settings.showRuler}
showNonPrintableChars={settings.showNonPrintableChars}
editorMode={settings.editorMode}
outlineExpanded={settings.outlineSidebarExpanded}
onToggleOutline={toggleOutlineSidebarExpanded}
onPageCountChange={handlePageCountChange}
onCurrentPageChange={handleCurrentPageChange}
onRegionContentChange={handleRegionContentChange}
onPageSetupChange={handlePageSetupPatch}
onRegionEditorChange={setRegionEditor}
onPageStackReady={(getPageStack) => {
getPageStackElementRef.current = getPageStack
}}
toolbarShellClassName={
settings.chromeCollapsed ? "docs-toolbar-shell--collapsed" : undefined
}
toolbar={
editable ? (
<DocsToolbar
editor={regionEditor ?? editor}
zoom={settings.zoom}
onZoomChange={setZoom}
spellcheck={settings.spellcheck}
onToggleSpellcheck={toggleSpellcheck}
showChromeToggle={Boolean(chrome)}
chromeCollapsed={settings.chromeCollapsed}
onToggleChromeCollapsed={toggleChromeCollapsed}
onPrint={() => void handlePrintDocument()}
embedded
/>
) : null
}
/>
<DocsAiPanel
editor={editor}
documentPath={session.canonicalPath}
documentTitle={chromeProps?.title ?? "Document"}
sourcePath={session.sourcePath}
editable={editable && settings.editorMode !== "view"}
/>
</div>
) : (
<div className="min-h-0 flex-1 overflow-auto">
<EditorContent editor={editor} className="h-full" />
@ -813,5 +979,6 @@ export function RichTextDocumentEditor({
/>
) : null}
</div>
</DocsParagraphStylesProvider>
)
}

View File

@ -5,9 +5,14 @@ import Link from "next/link"
import { useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/lib/api/client"
import { Button } from "@/components/ui/button"
import { ArrowLeft, Loader2 } from "lucide-react"
import { ArrowLeft } from "lucide-react"
import { RichTextDocumentEditor } from "@/components/drive/richtext-document"
import {
DocsEditorLoadingShell,
useDocsEditorLoadingState,
} from "@/components/drive/richtext/docs-editor-loading-shell"
import { useDriveFileById, useDriveMutations, useDriveShares } from "@/lib/api/hooks/use-drive-queries"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import { displayFileBaseName } from "@/lib/drive/display-file-name"
import { readDriveEditorReturnTo } from "@/lib/drive/drive-editor-return"
import { resolveRenameName } from "@/lib/drive/drive-default-name"
@ -33,8 +38,14 @@ function renameTargetPath(filePath: string, newName: string): string {
export function RichTextEditor({ fileId }: { fileId: string }) {
const queryClient = useQueryClient()
const identity = useChromeIdentity()
const { ready } = useAuthReady()
const setSharePath = useDriveUIStore((s) => s.setSharePath)
const { data: file, error: fileError, isLoading: fileLoading } = useDriveFileById(fileId)
const {
data: file,
error: fileError,
isPending: filePending,
isFetching: fileFetching,
} = useDriveFileById(fileId)
const displayPath = file?.path ?? ""
const [session, setSession] = useState<RichTextSessionResponse | null>(null)
const [sessionError, setSessionError] = useState<string | null>(null)
@ -154,21 +165,19 @@ export function RichTextEditor({ fileId }: { fileId: string }) {
]
)
const resolvingFile = !ready || filePending || fileFetching
const awaitingSession = Boolean(displayPath) && !session && !sessionError
const { documentLoading, documentPhase, onDocumentLoadingChange } = useDocsEditorLoadingState(
session?.roomId ?? displayPath ?? fileId
)
const error =
fileError instanceof Error
? fileError.message
: sessionError
if (fileLoading) {
return (
<div className="flex h-dvh items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (error || !file) {
return (
const errorView =
error || !file ? (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8">
<p className="text-sm text-muted-foreground">
{error ?? "Document introuvable"}
@ -180,24 +189,28 @@ export function RichTextEditor({ fileId }: { fileId: string }) {
</Link>
</Button>
</div>
)
}
) : null
return (
<div className="flex h-dvh min-h-0 flex-col">
{!session ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<DocsEditorLoadingShell
title={title || undefined}
resolvingFile={resolvingFile}
awaitingSession={awaitingSession}
documentLoading={Boolean(session) && documentLoading}
documentPhase={documentPhase}
error={!resolvingFile ? errorView : null}
>
{session && file && !error ? (
<RichTextDocumentEditor
session={session}
mode="edit"
userName={collabUserName}
userColor={collabUserColor}
chrome={chrome}
deferSplash
onLoadingChange={onDocumentLoadingChange}
/>
)}
</div>
) : null}
</DocsEditorLoadingShell>
)
}

View File

@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { Loader2 } from "lucide-react"
import { DocsLoadingSplash } from "@/components/drive/richtext/docs-loading-splash"
import { apiClient } from "@/lib/api/client"
import type { DriveFileInfo } from "@/lib/api/types"
import { stashDriveEditorReturnTo } from "@/lib/drive/drive-editor-return"
@ -53,9 +53,5 @@ export function RichTextEditorLegacyRedirect({
)
}
return (
<div className="flex h-dvh items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
return <DocsLoadingSplash phase="opening" />
}

View File

@ -19,10 +19,11 @@ export function CollabPresenceAvatars({
users: CollabPresenceUser[]
className?: string
}) {
if (users.length === 0) return null
const remoteUsers = users.filter((user) => !user.isLocal)
if (remoteUsers.length === 0) return null
const visible = users.slice(0, MAX_VISIBLE)
const overflow = users.length - visible.length
const visible = remoteUsers.slice(0, MAX_VISIBLE)
const overflow = remoteUsers.length - visible.length
return (
<TooltipProvider delayDuration={200}>
@ -31,19 +32,14 @@ export function CollabPresenceAvatars({
<Tooltip key={user.clientId}>
<TooltipTrigger asChild>
<div
className={cn(
"flex size-8 items-center justify-center rounded-full border-2 border-white text-xs font-semibold text-white shadow-sm dark:border-[#202124]",
user.isLocal && "ring-2 ring-[#1967d2]/40 ring-offset-1 ring-offset-white dark:ring-offset-[#202124]"
)}
className="flex size-8 items-center justify-center rounded-full border-2 border-white text-xs font-semibold text-white shadow-sm dark:border-[#202124]"
style={{ backgroundColor: user.color }}
aria-label={user.isLocal ? `${user.name} (vous)` : user.name}
aria-label={user.name}
>
{senderInitial(user.name)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{user.isLocal ? `${user.name} (vous)` : user.name}
</TooltipContent>
<TooltipContent side="bottom">{user.name}</TooltipContent>
</Tooltip>
))}
{overflow > 0 ? (
@ -57,7 +53,7 @@ export function CollabPresenceAvatars({
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{users.slice(MAX_VISIBLE).map((u) => u.name).join(", ")}
{remoteUsers.slice(MAX_VISIBLE).map((u) => u.name).join(", ")}
</TooltipContent>
</Tooltip>
) : null}

View File

@ -11,6 +11,11 @@ import { DocsLogoIcon } from "@/components/drive/richtext/docs-logo-icon"
import { DocsMenubar } from "@/components/drive/richtext/docs-menubar"
import type { DocsEditMenuActions, DocsEditMenuState } from "@/components/drive/richtext/docs-edit-menu"
import type { DocsFileMenuActions } from "@/components/drive/richtext/docs-file-menu"
import type {
DocsFormatMenuActions,
DocsFormatMenuState,
} from "@/components/drive/richtext/docs-format-menu"
import type { DocsInsertMenuActions } from "@/components/drive/richtext/docs-insert-menu"
import type { DocsViewMenuActions, DocsViewMenuState } from "@/components/drive/richtext/docs-view-menu"
import { DocsMoveButton } from "@/components/drive/richtext/docs-move-button"
import { Button } from "@/components/ui/button"
@ -67,6 +72,13 @@ export function DocsChrome({
editMenuActions,
editMenuState,
editMenuDisabled,
insertMenuActions,
insertMenuDialogs,
insertMenuDisabled,
insertMenuPageElementsEnabled,
formatMenuActions,
formatMenuState,
formatMenuDisabled,
renameSignal,
}: {
title: string
@ -94,6 +106,13 @@ export function DocsChrome({
editMenuActions?: DocsEditMenuActions
editMenuState?: DocsEditMenuState
editMenuDisabled?: boolean
insertMenuActions?: DocsInsertMenuActions
insertMenuDialogs?: ReactNode
insertMenuDisabled?: boolean
insertMenuPageElementsEnabled?: boolean
formatMenuActions?: DocsFormatMenuActions
formatMenuState?: DocsFormatMenuState
formatMenuDisabled?: boolean
renameSignal?: number
}) {
const shareIcon = resolveShareButtonIcon(shares)
@ -107,7 +126,7 @@ export function DocsChrome({
return (
<>
<header className="shrink-0 bg-white dark:bg-background">
<div className="flex min-h-[72px] items-center gap-0 px-2 py-1">
<div className="flex min-h-[72px] items-center gap-0 py-1 pl-2 pr-4">
<Button
variant="ghost"
size="icon"
@ -170,6 +189,12 @@ export function DocsChrome({
editMenuActions={editMenuActions}
editMenuState={editMenuState}
editMenuDisabled={editMenuDisabled}
insertMenuActions={insertMenuActions}
insertMenuDisabled={insertMenuDisabled}
insertMenuPageElementsEnabled={insertMenuPageElementsEnabled}
formatMenuActions={formatMenuActions}
formatMenuState={formatMenuState}
formatMenuDisabled={formatMenuDisabled}
/>
</div>
</div>
@ -200,6 +225,7 @@ export function DocsChrome({
</header>
{showShare ? <ShareDialog /> : null}
{fileMenuDialogs}
{insertMenuDialogs}
</>
)
}

View File

@ -0,0 +1,160 @@
"use client"
import { useMemo, useState } from "react"
import { ChevronRight, Folder, Loader2, Shapes } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useDriveList } from "@/lib/api/hooks/use-drive-queries"
import type { DriveFileInfo } from "@/lib/api/types"
import { displayFileName } from "@/lib/drive/display-file-name"
import {
DRIVE_BTN_GHOST,
DRIVE_DIALOG_CONTENT,
DRIVE_DIALOG_DIVIDER,
DRIVE_DIALOG_OVERLAY,
DRIVE_TEXT_PRIMARY,
DRIVE_TEXT_SECONDARY,
DRIVE_TEXT_TITLE,
} from "@/lib/drive/drive-dialog-styles"
import { normalizeDriveFolderPath } from "@/lib/drive/drive-sidebar-tree"
import { isExcalidrawFile } from "@/lib/drive/ultidraw-formats"
import { cn } from "@/lib/utils"
export function DocsDriveDrawPickerDialog({
open,
onOpenChange,
onPickDraw,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onPickDraw: (file: DriveFileInfo) => void | Promise<void>
}) {
const [browsePath, setBrowsePath] = useState("/")
const [loadingPath, setLoadingPath] = useState<string | null>(null)
const list = useDriveList(browsePath, 1, "", open)
const folders = useMemo(
() => (list.data?.files ?? []).filter((f) => f.type === "directory"),
[list.data?.files]
)
const drawings = useMemo(
() => (list.data?.files ?? []).filter((f) => f.type === "file" && isExcalidrawFile(f)),
[list.data?.files]
)
const crumbs = useMemo(() => {
const normalized = normalizeDriveFolderPath(browsePath)
if (normalized === "/") return [{ path: "/", label: "Mon Drive" }]
const parts = normalized.slice(1).split("/")
const out: { path: string; label: string }[] = [{ path: "/", label: "Mon Drive" }]
for (let i = 0; i < parts.length; i++) {
const path = "/" + parts.slice(0, i + 1).join("/")
out.push({ path, label: displayFileName(parts[i]!) })
}
return out
}, [browsePath])
const pickDraw = async (file: DriveFileInfo) => {
setLoadingPath(file.path)
try {
await onPickDraw(file)
onOpenChange(false)
} catch {
window.alert("Impossible de charger ce dessin depuis le Drive.")
} finally {
setLoadingPath(null)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
overlayClassName={DRIVE_DIALOG_OVERLAY}
className={cn(DRIVE_DIALOG_CONTENT, "flex max-h-[min(80vh,560px)] flex-col gap-0 sm:max-w-[480px]")}
>
<DialogHeader className="shrink-0 px-6 pb-3 pt-6">
<DialogTitle className={cn("text-base font-medium", DRIVE_TEXT_TITLE)}>
Dessin depuis Drive
</DialogTitle>
<DialogDescription className={cn("text-sm", DRIVE_TEXT_SECONDARY)}>
Choisissez un fichier UltiDraw (.excalidraw) dans votre Drive.
</DialogDescription>
</DialogHeader>
<div className={cn("shrink-0 px-6 pb-2", DRIVE_DIALOG_DIVIDER)}>
<div className="flex flex-wrap items-center gap-1 text-sm">
{crumbs.map((crumb, index) => (
<span key={crumb.path} className="inline-flex items-center gap-1">
{index > 0 ? <ChevronRight className="size-3.5 text-muted-foreground" /> : null}
<Button
type="button"
variant="ghost"
size="sm"
className={cn(DRIVE_BTN_GHOST, "h-7 px-2 text-sm")}
onClick={() => setBrowsePath(crumb.path)}
>
{crumb.label}
</Button>
</span>
))}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
{list.isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : folders.length === 0 && drawings.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
Aucun dessin dans ce dossier.
</p>
) : (
<ul className="space-y-0.5">
{folders.map((folder) => (
<li key={folder.path}>
<button
type="button"
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm hover:bg-accent"
onClick={() => setBrowsePath(folder.path)}
>
<Folder className="size-4 shrink-0 text-[#5f6368]" />
<span className={cn("truncate", DRIVE_TEXT_PRIMARY)}>
{displayFileName(folder.name)}
</span>
<ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground" />
</button>
</li>
))}
{drawings.map((file) => (
<li key={file.path}>
<button
type="button"
disabled={loadingPath === file.path}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm hover:bg-accent disabled:opacity-60"
onClick={() => void pickDraw(file)}
>
<Shapes className="size-4 shrink-0 text-[#1967d2]" />
<span className={cn("truncate", DRIVE_TEXT_PRIMARY)}>
{displayFileName(file.name)}
</span>
{loadingPath === file.path ? (
<Loader2 className="ml-auto size-4 shrink-0 animate-spin text-muted-foreground" />
) : null}
</button>
</li>
))}
</ul>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,175 @@
"use client"
import { useMemo, useState } from "react"
import { ChevronRight, Folder, ImageIcon, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useDriveList } from "@/lib/api/hooks/use-drive-queries"
import { fetchDrivePreviewBlob } from "@/lib/api/drive-download"
import type { DriveFileInfo } from "@/lib/api/types"
import { displayFileName } from "@/lib/drive/display-file-name"
import { drivePreviewKind } from "@/lib/drive/drive-preview"
import {
DRIVE_BTN_GHOST,
DRIVE_DIALOG_CONTENT,
DRIVE_DIALOG_DIVIDER,
DRIVE_DIALOG_OVERLAY,
DRIVE_TEXT_PRIMARY,
DRIVE_TEXT_SECONDARY,
DRIVE_TEXT_TITLE,
} from "@/lib/drive/drive-dialog-styles"
import { normalizeDriveFolderPath } from "@/lib/drive/drive-sidebar-tree"
import { cn } from "@/lib/utils"
async function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(blob)
})
}
export function DocsDriveImagePickerDialog({
open,
onOpenChange,
onPickImage,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onPickImage: (src: string, file: DriveFileInfo) => void | Promise<void>
}) {
const [browsePath, setBrowsePath] = useState("/")
const [loadingPath, setLoadingPath] = useState<string | null>(null)
const list = useDriveList(browsePath, 1, "", open)
const folders = useMemo(
() => (list.data?.files ?? []).filter((f) => f.type === "directory"),
[list.data?.files]
)
const images = useMemo(
() =>
(list.data?.files ?? []).filter(
(f) => f.type === "file" && drivePreviewKind(f) === "image"
),
[list.data?.files]
)
const crumbs = useMemo(() => {
const normalized = normalizeDriveFolderPath(browsePath)
if (normalized === "/") return [{ path: "/", label: "Mon Drive" }]
const parts = normalized.slice(1).split("/")
const out: { path: string; label: string }[] = [{ path: "/", label: "Mon Drive" }]
for (let i = 0; i < parts.length; i++) {
const path = "/" + parts.slice(0, i + 1).join("/")
out.push({ path, label: displayFileName(parts[i]!) })
}
return out
}, [browsePath])
const pickImage = async (file: DriveFileInfo) => {
setLoadingPath(file.path)
try {
const blob = await fetchDrivePreviewBlob(file)
const src = await blobToDataUrl(blob)
await onPickImage(src, file)
onOpenChange(false)
} catch {
window.alert("Impossible de charger cette image depuis le Drive.")
} finally {
setLoadingPath(null)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
overlayClassName={DRIVE_DIALOG_OVERLAY}
className={cn(DRIVE_DIALOG_CONTENT, "flex max-h-[min(80vh,560px)] flex-col gap-0 sm:max-w-[480px]")}
>
<DialogHeader className="shrink-0 px-6 pb-3 pt-6">
<DialogTitle className={cn("text-base font-medium", DRIVE_TEXT_TITLE)}>
Image depuis Drive
</DialogTitle>
<DialogDescription className={cn("text-sm", DRIVE_TEXT_SECONDARY)}>
Choisissez une image dans votre UltiDrive.
</DialogDescription>
</DialogHeader>
<div className={cn("shrink-0 px-6 pb-2", DRIVE_DIALOG_DIVIDER)}>
<div className="flex flex-wrap items-center gap-1 text-sm">
{crumbs.map((crumb, index) => (
<span key={crumb.path} className="inline-flex items-center gap-1">
{index > 0 ? <ChevronRight className="size-3.5 text-muted-foreground" /> : null}
<Button
type="button"
variant="ghost"
size="sm"
className={cn(DRIVE_BTN_GHOST, "h-7 px-2 text-sm")}
onClick={() => setBrowsePath(crumb.path)}
>
{crumb.label}
</Button>
</span>
))}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
{list.isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : folders.length === 0 && images.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
Aucune image dans ce dossier.
</p>
) : (
<ul className="space-y-0.5">
{folders.map((folder) => (
<li key={folder.path}>
<button
type="button"
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm hover:bg-accent"
onClick={() => setBrowsePath(folder.path)}
>
<Folder className="size-4 shrink-0 text-[#5f6368]" />
<span className={cn("truncate", DRIVE_TEXT_PRIMARY)}>
{displayFileName(folder.name)}
</span>
<ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground" />
</button>
</li>
))}
{images.map((file) => (
<li key={file.path}>
<button
type="button"
disabled={loadingPath === file.path}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm hover:bg-accent disabled:opacity-60"
onClick={() => void pickImage(file)}
>
<ImageIcon className="size-4 shrink-0 text-[#1967d2]" />
<span className={cn("truncate", DRIVE_TEXT_PRIMARY)}>
{displayFileName(file.name)}
</span>
{loadingPath === file.path ? (
<Loader2 className="ml-auto size-4 shrink-0 animate-spin text-muted-foreground" />
) : null}
</button>
</li>
))}
</ul>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,63 @@
"use client"
import { useCallback, useEffect, useState, type ReactNode } from "react"
import { DocsLoadingSplash } from "@/components/drive/richtext/docs-loading-splash"
import {
resolveDocsShellSplashPhase,
type DocsLoadingPhase,
} from "@/lib/drive/docs-loading-phase"
export function useDocsEditorLoadingState(
resetKey?: string | null,
initialPhase: DocsLoadingPhase = "connecting"
) {
const [documentLoading, setDocumentLoading] = useState(true)
const [documentPhase, setDocumentPhase] = useState<DocsLoadingPhase>(initialPhase)
useEffect(() => {
setDocumentLoading(true)
setDocumentPhase(initialPhase)
}, [resetKey, initialPhase])
const onDocumentLoadingChange = useCallback((loading: boolean, phase?: DocsLoadingPhase) => {
setDocumentLoading(loading)
if (phase) setDocumentPhase(phase)
}, [])
return { documentLoading, documentPhase, onDocumentLoadingChange }
}
export function DocsEditorLoadingShell({
title,
resolvingFile,
awaitingSession,
documentLoading,
documentPhase,
error,
children,
}: {
title?: string
resolvingFile: boolean
awaitingSession: boolean
documentLoading: boolean
documentPhase: DocsLoadingPhase
error?: ReactNode
children?: ReactNode
}) {
const showSplash = resolvingFile || awaitingSession || documentLoading
const splashPhase = resolveDocsShellSplashPhase({
resolvingFile,
awaitingSession,
documentPhase,
})
return (
<div className="relative flex h-dvh min-h-0 flex-col">
{children}
{showSplash && !error ? (
<DocsLoadingSplash phase={splashPhase} title={title} overlay />
) : null}
{error}
</div>
)
}

View File

@ -13,6 +13,17 @@ import { docsZoomToScale } from "@/lib/drive/docs-ruler-scale"
import { useDocsRulerSync } from "@/lib/drive/use-docs-ruler-sync"
import { useDocsRulerMarginDrag } from "@/components/drive/richtext/use-docs-ruler-margin-drag"
import { DocsRulerMarginDragTooltip } from "@/components/drive/richtext/docs-ruler-markers"
import { DocsGraphicFloatingToolbar } from "@/components/drive/richtext/docs-graphic-floating-toolbar"
import { DocsTableFloatingToolbar } from "@/components/drive/richtext/docs-table-floating-toolbar"
import {
DOCS_GRAPHIC_DRAW_EVENT,
DocsGraphicDrawModal,
} from "@/components/drive/richtext/docs-graphic-draw-modal"
import {
DOCS_GRAPHIC_OPTIONS_EVENT,
DOCS_GRAPHIC_OPTIONS_SIDEBAR_WIDTH_PX,
DocsGraphicOptionsSidebar,
} from "@/components/drive/richtext/docs-graphic-options-sidebar"
import { cn } from "@/lib/utils"
export function DocsEditorWorkspace({
@ -33,6 +44,7 @@ export function DocsEditorWorkspace({
onRegionContentChange,
onPageSetupChange,
onRegionEditorChange,
onPageStackReady,
}: {
editor: Editor
pageLayout: DocPageLayout
@ -58,16 +70,41 @@ export function DocsEditorWorkspace({
options?: { immediate?: boolean }
) => void
onRegionEditorChange?: (editor: import("@tiptap/react").Editor | null) => void
onPageStackReady?: (getPageStack: () => HTMLElement | null) => void
}) {
const canvasRef = useRef<HTMLDivElement>(null)
const rulerTrackRef = useRef<HTMLDivElement>(null)
const [pageCount, setPageCount] = useState(1)
const [narrowViewport, setNarrowViewport] = useState(false)
const [graphicOptionsOpen, setGraphicOptionsOpen] = useState(false)
const [graphicOptionsSection, setGraphicOptionsSection] =
useState<import("@/lib/drive/docs-graphic-ui").DocsGraphicOptionsSection | null>(null)
const [graphicDrawOpen, setGraphicDrawOpen] = useState(false)
useEffect(() => {
const onOpenOptions = (event: Event) => {
const detail = (event as CustomEvent<{ section?: import("@/lib/drive/docs-graphic-ui").DocsGraphicOptionsSection }>).detail
setGraphicOptionsSection(detail?.section ?? null)
setGraphicOptionsOpen(true)
}
const onOpenDraw = () => setGraphicDrawOpen(true)
window.addEventListener(DOCS_GRAPHIC_OPTIONS_EVENT, onOpenOptions)
window.addEventListener(DOCS_GRAPHIC_DRAW_EVENT, onOpenDraw)
return () => {
window.removeEventListener(DOCS_GRAPHIC_OPTIONS_EVENT, onOpenOptions)
window.removeEventListener(DOCS_GRAPHIC_DRAW_EVENT, onOpenDraw)
}
}, [])
const rulersVisible = showLayout && showRuler
const scale = docsZoomToScale(zoom)
const showToolbarShell = Boolean(toolbar) || rulersVisible
const marginsEditable = editable && editorMode !== "view"
const graphicOptionsSidebarOpen =
graphicOptionsOpen && editable && editorMode !== "view"
const graphicOptionsSidebarInset = graphicOptionsSidebarOpen
? DOCS_GRAPHIC_OPTIONS_SIDEBAR_WIDTH_PX
: 0
const {
pageLayoutWithMargins,
@ -95,9 +132,30 @@ export function DocsEditorWorkspace({
onCurrentPageChange?.(rulerSync.currentPage + 1)
}, [onCurrentPageChange, rulerSync.currentPage])
useEffect(() => {
onPageStackReady?.(
() => canvasRef.current?.querySelector<HTMLElement>("[data-docs-page-stack]") ?? null
)
}, [onPageStackReady, pageCount, showLayout, zoom])
return (
<div className="docs-editor-workspace flex min-h-0 flex-1 flex-col">
<DocsRulerMarginDragTooltip tooltip={dragTooltip} />
<DocsGraphicFloatingToolbar
editor={editor}
canvasRef={canvasRef}
disabled={!editable || editorMode === "view"}
/>
<DocsTableFloatingToolbar
editor={editor}
canvasRef={canvasRef}
disabled={!editable || editorMode === "view"}
/>
<DocsGraphicDrawModal
editor={editor}
open={graphicDrawOpen && editable && editorMode !== "view"}
onClose={() => setGraphicDrawOpen(false)}
/>
{showToolbarShell ? (
<div
className={cn(
@ -112,6 +170,7 @@ export function DocsEditorWorkspace({
scale={scale}
rulerSync={rulerSync}
rulerTrackRef={rulerTrackRef}
contentInsetRight={graphicOptionsSidebarInset}
outlineExpanded={outlineExpanded}
onToggleOutline={onToggleOutline}
editable={marginsEditable}
@ -137,31 +196,43 @@ export function DocsEditorWorkspace({
) : null}
<div
className="flex h-full min-h-0 flex-col"
className="flex h-full min-h-0 flex-row"
style={
rulersVisible
? { paddingLeft: DOCS_VERTICAL_RULER_WIDTH_PX }
: undefined
}
>
<DocsPageView
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col">
<DocsPageView
editor={editor}
pageLayout={pageLayoutWithMargins}
zoom={zoom}
editable={editable}
showLayout={showLayout}
showRuler={false}
showNonPrintableChars={showNonPrintableChars}
editorMode={editorMode}
canvasRef={canvasRef}
onPageCountChange={(count) => {
setPageCount(count)
onPageCountChange?.(count)
}}
onNarrowViewportChange={setNarrowViewport}
onRegionContentChange={onRegionContentChange}
onPageSetupChange={onPageSetupChange}
onRegionEditorChange={onRegionEditorChange}
/>
</div>
<DocsGraphicOptionsSidebar
editor={editor}
pageLayout={pageLayoutWithMargins}
zoom={zoom}
editable={editable}
showLayout={showLayout}
showRuler={false}
showNonPrintableChars={showNonPrintableChars}
editorMode={editorMode}
canvasRef={canvasRef}
onPageCountChange={(count) => {
setPageCount(count)
onPageCountChange?.(count)
open={graphicOptionsSidebarOpen}
focusSection={graphicOptionsSection}
onClose={() => {
setGraphicOptionsOpen(false)
setGraphicOptionsSection(null)
}}
onNarrowViewportChange={setNarrowViewport}
onRegionContentChange={onRegionContentChange}
onPageSetupChange={onPageSetupChange}
onRegionEditorChange={onRegionEditorChange}
/>
</div>
</div>

View File

@ -0,0 +1,159 @@
"use client"
import dynamic from "next/dynamic"
import { memo, useCallback, useState } from "react"
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"
import type { DocsGraphicDrawSavePayload } from "@/lib/drive/docs-graphic-draw-bridge"
export function docsExcalidrawEditorKey(drawScene: string | null): string {
if (!drawScene) return "new-draw"
return `draw-${drawScene.length}-${drawScene.slice(0, 48)}`
}
const ExcalidrawEditor = dynamic(
async () => {
await import("@excalidraw/excalidraw/index.css")
const { Excalidraw, restoreElements, restoreAppState } = await import(
"@excalidraw/excalidraw"
)
type ExcalidrawInitialDataState = import("@excalidraw/excalidraw/types").ExcalidrawInitialDataState
type BinaryFiles = import("@excalidraw/excalidraw/types").BinaryFiles
const { memo, useEffect, useMemo, useRef } = await import("react")
function parseInitialData(drawScene: string | null): ExcalidrawInitialDataState | undefined {
if (!drawScene) return undefined
try {
const data = JSON.parse(drawScene) as {
elements?: Parameters<typeof restoreElements>[0]
appState?: Parameters<typeof restoreAppState>[0]
files?: BinaryFiles
}
const initialData: ExcalidrawInitialDataState = {
elements: restoreElements(data.elements ?? [], null),
appState: restoreAppState(data.appState ?? {}, null),
files: data.files,
}
return initialData
} catch {
return undefined
}
}
const Inner = memo(function Inner({
drawScene,
onReady,
}: {
drawScene: string | null
onReady: (api: ExcalidrawImperativeAPI) => void
}) {
const apiRef = useRef<ExcalidrawImperativeAPI | null>(null)
const initialData = useMemo(() => parseInitialData(drawScene), [drawScene])
useEffect(() => {
const api = apiRef.current
if (!api || !drawScene) return
const data = parseInitialData(drawScene)
if (!data) return
api.updateScene({
elements: data.elements ?? [],
appState: data.appState,
files: data.files,
})
}, [drawScene])
return (
<div className="h-full min-h-0 w-full">
<Excalidraw
excalidrawAPI={(api) => {
apiRef.current = api
onReady(api)
}}
initialData={initialData}
langCode="fr-FR"
UIOptions={{
canvasActions: {
changeViewBackgroundColor: true,
clearCanvas: true,
export: false,
loadScene: false,
saveToActiveFile: false,
toggleTheme: false,
},
}}
/>
</div>
)
})
return Inner
},
{
ssr: false,
loading: () => (
<div className="flex h-full min-h-[360px] items-center justify-center text-sm text-muted-foreground">
Chargement de l&apos;éditeur
</div>
),
}
)
function readSvgDimensions(svg: SVGSVGElement): { width: number; height: number } {
const viewBox = svg.getAttribute("viewBox")
if (viewBox) {
const parts = viewBox.split(/\s+/).map(Number)
if (parts.length === 4 && parts.every((n) => Number.isFinite(n))) {
return {
width: Math.max(24, Math.round(parts[2])),
height: Math.max(24, Math.round(parts[3])),
}
}
}
const width = Number(svg.getAttribute("width"))
const height = Number(svg.getAttribute("height"))
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
return { width: Math.round(width), height: Math.round(height) }
}
return { width: 320, height: 240 }
}
function DocsExcalidrawEditorInner({
drawScene,
onReady,
}: {
drawScene: string | null
onReady: (api: ExcalidrawImperativeAPI) => void
}) {
return <ExcalidrawEditor drawScene={drawScene} onReady={onReady} />
}
export const DocsExcalidrawEditor = memo(DocsExcalidrawEditorInner)
export function useDocsExcalidrawSave(api: ExcalidrawImperativeAPI | null) {
const [saving, setSaving] = useState(false)
const exportDrawing = useCallback(async (): Promise<DocsGraphicDrawSavePayload | null> => {
if (!api) return null
setSaving(true)
try {
const elements = api.getSceneElements()
const appState = api.getAppState()
const files = api.getFiles()
const { exportToSvg, serializeAsJSON } = await import("@excalidraw/excalidraw")
const drawScene = serializeAsJSON(elements, appState, files, "local")
const svg = await exportToSvg({
elements,
appState,
files,
skipInliningFonts: true,
})
const svgString = new XMLSerializer().serializeToString(svg)
const src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`
const { width, height } = readSvgDimensions(svg)
return { drawScene, src, width, height }
} finally {
setSaving(false)
}
}, [api])
return { exportDrawing, saving }
}

View File

@ -1,14 +1,18 @@
"use client"
import {
Children,
cloneElement,
createContext,
isValidElement,
useCallback,
useContext,
useId,
useState,
type ReactElement,
type ReactNode,
} from "react"
import { MenubarSub } from "@/components/ui/menubar"
import { MenubarSub, MenubarSubContent } from "@/components/ui/menubar"
type ExclusiveMenuSubContextValue = {
openId: string | null
@ -27,6 +31,21 @@ export function DocsExclusiveMenuSubRoot({ children }: { children: ReactNode })
)
}
function isMenubarSubContentElement(child: ReactNode): child is ReactElement<{ children?: ReactNode }> {
return isValidElement(child) && child.type === MenubarSubContent
}
/** Nested exclusive subs get their own open-id scope so parents stay open. */
function withNestedExclusiveSubRoot(children: ReactNode): ReactNode {
return Children.map(children, (child) => {
if (!isMenubarSubContentElement(child)) return child
return cloneElement(child, {
children: <DocsExclusiveMenuSubRoot>{child.props.children}</DocsExclusiveMenuSubRoot>,
})
})
}
export function DocsExclusiveMenuSub({
id,
children,
@ -42,7 +61,14 @@ export function DocsExclusiveMenuSub({
const onOpenChange = useCallback(
(next: boolean) => {
if (!ctx) return
ctx.setOpenId(next ? subId : null)
if (next) {
ctx.setOpenId(subId)
return
}
// Only clear if this sub is still the active one (avoid race when switching siblings).
if (ctx.openId === subId) {
ctx.setOpenId(null)
}
},
[ctx, subId]
)
@ -53,7 +79,7 @@ export function DocsExclusiveMenuSub({
return (
<MenubarSub open={open} onOpenChange={onOpenChange}>
{children}
{withNestedExclusiveSubRoot(children)}
</MenubarSub>
)
}

View File

@ -0,0 +1,203 @@
"use client"
import type { ReactNode } from "react"
import {
DOCS_BULLET_STYLE_PRESETS,
DOCS_ORDERED_STYLE_PRESETS,
type DocsBulletStyleId,
type DocsChecklistStyleId,
type DocsOrderedStyleId,
} from "@/lib/drive/docs-list-styles"
import { cn } from "@/lib/utils"
function PresetButton({
disabled,
active,
onClick,
children,
className,
label,
}: {
disabled?: boolean
active?: boolean
onClick?: () => void
children: ReactNode
className?: string
label: string
}) {
return (
<button
type="button"
disabled={disabled}
aria-label={label}
aria-pressed={active}
onClick={onClick}
className={cn(
"rounded border border-[#dadce0] bg-white p-2 text-left text-[10px] leading-tight text-[#3c4043]",
"transition-colors hover:bg-[#f1f3f4] disabled:cursor-not-allowed disabled:opacity-50",
"dark:border-border dark:bg-background dark:text-foreground dark:hover:bg-muted",
active && "border-[#1a73e8] ring-1 ring-[#1a73e8]",
className
)}
>
{children}
</button>
)
}
export function DocsFormatColumnPresets({ disabled }: { disabled?: boolean }) {
return (
<div className="grid grid-cols-3 gap-2 px-1">
{[1, 2, 3].map((count) => (
<PresetButton
key={count}
disabled={disabled}
active={count === 1}
label={`${count} colonne${count > 1 ? "s" : ""}`}
className="flex h-14 items-stretch justify-center gap-0.5 p-1.5"
>
{Array.from({ length: count }, (_, index) => (
<span
key={index}
className="flex flex-1 flex-col justify-center gap-0.5 rounded-sm bg-[#e8eaed] px-0.5 py-1 dark:bg-muted"
>
<span className="block h-px w-full bg-[#9aa0a6]" />
<span className="block h-px w-full bg-[#9aa0a6]" />
<span className="block h-px w-3/4 bg-[#9aa0a6]" />
</span>
))}
</PresetButton>
))}
</div>
)
}
const NUMBERED_PRESET_LINES: Record<DocsOrderedStyleId, readonly string[]> = {
decimal: ["1.", " a.", " i."],
"decimal-paren": ["1)", " a)", " i)"],
outline: ["1.", " 1.1.", " 1.1.1."],
"upper-alpha": ["A.", " a.", " i.", "B."],
"upper-roman": ["I.", " A.", " 1.", "II."],
"zero-padded": ["01.", "02."],
}
export function DocsFormatNumberedPresets({
disabled,
activeStyleId,
onSelect,
}: {
disabled?: boolean
activeStyleId?: DocsOrderedStyleId | null | "mixed"
onSelect: (styleId: DocsOrderedStyleId) => void
}) {
return (
<div className="grid grid-cols-3 gap-2">
{DOCS_ORDERED_STYLE_PRESETS.map((preset) => (
<PresetButton
key={preset.id}
disabled={disabled}
active={activeStyleId !== "mixed" && activeStyleId === preset.id}
label={`Liste numérotée ${preset.id}`}
onClick={() => onSelect(preset.id)}
className="min-h-[72px] font-mono"
>
{NUMBERED_PRESET_LINES[preset.id].map((line) => (
<span key={line} className="block whitespace-pre">
{line}
</span>
))}
</PresetButton>
))}
</div>
)
}
export function DocsFormatBulletPresets({
disabled,
activeStyleId,
onSelect,
}: {
disabled?: boolean
activeStyleId?: DocsBulletStyleId | null | "mixed"
onSelect: (styleId: DocsBulletStyleId) => void
}) {
const symbols: Record<DocsBulletStyleId, string> = {
disc: "•",
circle: "◦",
square: "▪",
arrow: "➤",
check: "✓",
dash: "",
}
return (
<div className="grid grid-cols-3 gap-2">
{DOCS_BULLET_STYLE_PRESETS.map((preset) => (
<PresetButton
key={preset.id}
disabled={disabled}
active={activeStyleId !== "mixed" && activeStyleId === preset.id}
label={`Liste à puces ${preset.id}`}
onClick={() => onSelect(preset.id)}
className="min-h-[56px]"
>
<span className="block">
{symbols[preset.id]} Élément
</span>
<span className="block">
{symbols[preset.id]} Élément
</span>
</PresetButton>
))}
</div>
)
}
export function DocsFormatChecklistPresets({
disabled,
activeStyleId,
onSelect,
}: {
disabled?: boolean
activeStyleId?: DocsChecklistStyleId | null | "mixed"
onSelect: (styleId: DocsChecklistStyleId) => void
}) {
return (
<div className="grid grid-cols-2 gap-2">
<PresetButton
disabled={disabled}
active={activeStyleId !== "mixed" && activeStyleId === "strikethrough"}
label="Checklist avec texte barré"
className="min-h-[56px]"
onClick={() => onSelect("strikethrough")}
>
<span className="flex items-center gap-1.5">
<span className="inline-flex size-3 shrink-0 rounded-sm border border-[#5f6368]" />
Tâche
</span>
<span className="flex items-center gap-1.5 line-through opacity-70">
<span className="inline-flex size-3 shrink-0 items-center justify-center rounded-sm border border-[#1a73e8] bg-[#1a73e8] text-[8px] text-white">
</span>
Terminé
</span>
</PresetButton>
<PresetButton
disabled={disabled}
active={activeStyleId !== "mixed" && activeStyleId === "simple"}
label="Checklist simple"
className="min-h-[56px]"
onClick={() => onSelect("simple")}
>
<span className="flex items-center gap-1.5">
<span className="inline-flex size-3 shrink-0 rounded-sm border border-[#5f6368]" />
Tâche
</span>
<span className="flex items-center gap-1.5">
<span className="inline-flex size-3 shrink-0 rounded-sm border border-[#5f6368]" />
Tâche
</span>
</PresetButton>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,7 @@ import {
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { openDocsGraphicDrawEditor } from "@/lib/drive/docs-graphic-draw-bridge"
import {
DOCS_GRAPHIC_WRAP_LABELS,
parseGraphicAttrs,
@ -39,7 +40,7 @@ export function DocsGraphicContextMenu({
const isImage = attrs.graphicType === "image"
const applyWrap = (wrap: DocsGraphicWrap) => {
editor.chain().focus().setDocsGraphicWrap(wrap).run()
editor.chain().setDocsGraphicWrap(wrap).run()
}
const downloadImage = () => {
@ -53,7 +54,10 @@ export function DocsGraphicContextMenu({
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="min-w-52">
<ContextMenuContent
className="min-w-52"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<ContextMenuItem
onClick={() => document.execCommand("cut")}
>
@ -92,6 +96,10 @@ export function DocsGraphicContextMenu({
Télécharger l&apos;image
</ContextMenuItem>
</>
) : attrs.graphicType === "draw" ? (
<ContextMenuItem onClick={() => openDocsGraphicDrawEditor(editor)}>
Modifier le dessin
</ContextMenuItem>
) : attrs.graphicType === "shape" ? (
<>
<ContextMenuItem
@ -115,7 +123,7 @@ export function DocsGraphicContextMenu({
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>Habillage texte</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuSubContent onCloseAutoFocus={(event) => event.preventDefault()}>
{(Object.keys(DOCS_GRAPHIC_WRAP_LABELS) as DocsGraphicWrap[]).map((wrap) => (
<ContextMenuItem key={wrap} onClick={() => applyWrap(wrap)}>
{DOCS_GRAPHIC_WRAP_LABELS[wrap]}

View File

@ -1,70 +1,74 @@
"use client"
import { useCallback, useEffect, useRef } from "react"
import type { DocsGraphicAttrs } from "@/lib/drive/docs-graphic-types"
import {
computeCropDisplayGeometry,
resizeCropRegion,
type DocsGraphicAttrs,
} from "@/lib/drive/docs-graphic-types"
import { cn } from "@/lib/utils"
const HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"] as const
type Handle = (typeof HANDLES)[number]
const HANDLE_CLASS: Record<Handle, string> = {
nw: "left-0 top-0 cursor-nwse-resize",
n: "left-1/2 top-0 -translate-x-1/2 cursor-ns-resize",
ne: "right-0 top-0 cursor-nesw-resize",
e: "right-0 top-1/2 -translate-y-1/2 cursor-ew-resize",
se: "right-0 bottom-0 cursor-nwse-resize",
s: "left-1/2 bottom-0 -translate-x-1/2 cursor-ns-resize",
sw: "left-0 bottom-0 cursor-nesw-resize",
w: "left-0 top-1/2 -translate-y-1/2 cursor-ew-resize",
nw: "docs-crop-handle--corner docs-crop-handle--nw cursor-nwse-resize",
n: "docs-crop-handle--edge docs-crop-handle--n cursor-ns-resize",
ne: "docs-crop-handle--corner docs-crop-handle--ne cursor-nesw-resize",
e: "docs-crop-handle--edge docs-crop-handle--e cursor-ew-resize",
se: "docs-crop-handle--corner docs-crop-handle--se cursor-nwse-resize",
s: "docs-crop-handle--edge docs-crop-handle--s cursor-ns-resize",
sw: "docs-crop-handle--corner docs-crop-handle--sw cursor-nesw-resize",
w: "docs-crop-handle--edge docs-crop-handle--w cursor-ew-resize",
}
function clamp01(v: number): number {
return Math.min(1, Math.max(0, v))
}
type CropRegion = Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">
function resizeCrop(
handle: Handle,
crop: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">,
dxNorm: number,
dyNorm: number
): Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight"> {
let { cropX, cropY, cropWidth, cropHeight } = crop
if (handle.includes("e")) cropWidth = clamp01(cropWidth + dxNorm)
if (handle.includes("w")) {
cropWidth = clamp01(cropWidth - dxNorm)
cropX = clamp01(cropX + dxNorm)
}
if (handle.includes("s")) cropHeight = clamp01(cropHeight + dyNorm)
if (handle.includes("n")) {
cropHeight = clamp01(cropHeight - dyNorm)
cropY = clamp01(cropY + dyNorm)
}
cropWidth = Math.max(0.05, cropWidth)
cropHeight = Math.max(0.05, cropHeight)
return { cropX, cropY, cropWidth, cropHeight }
function pct(value: number, total: number): string {
return `${(value / Math.max(total, 1)) * 100}%`
}
export function DocsGraphicCropOverlay({
attrs,
cropEditBase,
frameWidth,
frameHeight,
imageNaturalWidth,
imageNaturalHeight,
onChange,
onDone,
}: {
attrs: DocsGraphicAttrs
cropEditBase?: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight"> | null
frameWidth: number
frameHeight: number
imageNaturalWidth: number
imageNaturalHeight: number
onChange: (patch: Partial<DocsGraphicAttrs>) => void
onDone: () => void
}) {
const rootRef = useRef<HTMLDivElement>(null)
const { windowRect, cropRect } = computeCropDisplayGeometry(
attrs,
frameWidth,
frameHeight,
imageNaturalWidth,
imageNaturalHeight,
cropEditBase
)
const dragRef = useRef<{
handle: Handle
startX: number
startY: number
origin: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">
origin: CropRegion
} | null>(null)
const onHandleDown = useCallback(
const readScale = useCallback(() => {
const rootRect = rootRef.current?.getBoundingClientRect()
return rootRect && windowRect.width > 0 ? rootRect.width / windowRect.width : 1
}, [windowRect.width])
const beginResize = useCallback(
(handle: Handle, event: React.PointerEvent) => {
event.preventDefault()
event.stopPropagation()
@ -88,53 +92,118 @@ export function DocsGraphicCropOverlay({
const onMove = (event: PointerEvent) => {
if (!dragRef.current) return
const { handle, startX, startY, origin } = dragRef.current
const dxNorm = (event.clientX - startX) / Math.max(frameWidth, 1)
const dyNorm = (event.clientY - startY) / Math.max(frameHeight, 1)
onChange(resizeCrop(handle, origin, dxNorm, dyNorm))
const scale = readScale()
const dxPx = (event.clientX - startX) / scale
const dyPx = (event.clientY - startY) / scale
const dxNorm = dxPx / Math.max(windowRect.width, 1)
const dyNorm = dyPx / Math.max(windowRect.height, 1)
onChange(resizeCropRegion(handle, origin, dxNorm, dyNorm))
}
const onUp = () => {
dragRef.current = null
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
window.addEventListener("pointercancel", onUp)
return () => {
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
window.removeEventListener("pointercancel", onUp)
}
}, [frameHeight, frameWidth, onChange])
}, [onChange, readScale, windowRect.height, windowRect.width])
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onDone()
if (event.key === "Escape" || event.key === "Enter") {
event.preventDefault()
onDone()
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
window.addEventListener("keydown", onKey, true)
return () => window.removeEventListener("keydown", onKey, true)
}, [onDone])
const left = `${attrs.cropX * 100}%`
const top = `${attrs.cropY * 100}%`
const width = `${attrs.cropWidth * 100}%`
const height = `${attrs.cropHeight * 100}%`
const localCropLeft = cropRect.left - windowRect.left
const localCropTop = cropRect.top - windowRect.top
const cropWidth = pct(cropRect.width, windowRect.width)
const cropHeight = pct(cropRect.height, windowRect.height)
const cropLeft = pct(localCropLeft, windowRect.width)
const cropTop = pct(localCropTop, windowRect.height)
const shadeTop = localCropTop
const shadeLeft = localCropLeft
const shadeRight = windowRect.width - (localCropLeft + cropRect.width)
const shadeBottom = windowRect.height - (localCropTop + cropRect.height)
return (
<div className="docs-graphic-crop absolute inset-0 z-30" aria-label="Recadrage">
<div className="absolute inset-0 bg-black/40" />
<div
ref={rootRef}
className="docs-graphic-crop pointer-events-none absolute z-30"
aria-label="Recadrage"
style={{
left: windowRect.left,
top: windowRect.top,
width: windowRect.width,
height: windowRect.height,
}}
onDoubleClick={(event) => {
event.preventDefault()
event.stopPropagation()
onDone()
}}
>
{shadeTop > 0 ? (
<div
className="pointer-events-none absolute left-0 top-0 bg-black/40"
style={{ width: "100%", height: pct(shadeTop, windowRect.height) }}
/>
) : null}
{shadeBottom > 0 ? (
<div
className="pointer-events-none absolute bottom-0 left-0 bg-black/40"
style={{ width: "100%", height: pct(shadeBottom, windowRect.height) }}
/>
) : null}
{shadeLeft > 0 ? (
<div
className="pointer-events-none absolute bg-black/40"
style={{
left: 0,
top: cropTop,
width: pct(shadeLeft, windowRect.width),
height: cropHeight,
}}
/>
) : null}
{shadeRight > 0 ? (
<div
className="pointer-events-none absolute bg-black/40"
style={{
right: 0,
top: cropTop,
width: pct(shadeRight, windowRect.width),
height: cropHeight,
}}
/>
) : null}
<div
className={cn(
"absolute border-2 border-white shadow-[0_0_0_1px_#1a73e8]",
"docs-crop-region pointer-events-none absolute",
attrs.cropShape === "ellipse" && "rounded-full"
)}
style={{ left, top, width, height }}
style={{ left: cropLeft, top: cropTop, width: cropWidth, height: cropHeight }}
>
<div
className="docs-crop-region__frame pointer-events-none absolute inset-0 border border-dashed border-white/95 shadow-[0_0_0_1px_rgba(0,0,0,0.45)]"
aria-hidden
/>
{HANDLES.map((handle) => (
<span
key={handle}
role="presentation"
className={cn(
"absolute z-40 size-2.5 rounded-full border border-white bg-[#1a73e8] shadow",
HANDLE_CLASS[handle]
)}
onPointerDown={(event) => onHandleDown(handle, event)}
className={cn("docs-crop-handle pointer-events-auto absolute z-40", HANDLE_CLASS[handle])}
onPointerDown={(event) => beginResize(handle, event)}
/>
))}
</div>

View File

@ -0,0 +1,117 @@
"use client"
import { memo, useState } from "react"
import type { Editor } from "@tiptap/react"
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"
import { Shapes } from "lucide-react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
DocsExcalidrawEditor,
docsExcalidrawEditorKey,
useDocsExcalidrawSave,
} from "@/components/drive/richtext/docs-excalidraw-editor"
import {
DOCS_GRAPHIC_DRAW_EVENT,
notifyDocsGraphicDrawSaved,
openDocsGraphicDrawModal,
readSelectedGraphicAttrs,
} from "@/lib/drive/docs-graphic-draw-bridge"
import {
DOCS_GRAPHIC_DRAW_DIALOG_CONTENT,
DOCS_GRAPHIC_DRAW_DIALOG_OVERLAY,
} from "@/lib/drive/drive-dialog-styles"
export { openDocsGraphicDrawModal, DOCS_GRAPHIC_DRAW_EVENT }
function DocsGraphicDrawModalInner({
open,
editor,
onClose,
}: {
open: boolean
editor: Editor | null
onClose: () => void
}) {
const [api, setApi] = useState<ExcalidrawImperativeAPI | null>(null)
const { exportDrawing, saving } = useDocsExcalidrawSave(api)
const selectedAttrs = open ? readSelectedGraphicAttrs(editor) : null
const drawScene = selectedAttrs?.drawScene ?? null
const editorKey = docsExcalidrawEditorKey(drawScene)
const handleValidate = async () => {
const payload = await exportDrawing()
if (!payload || !editor) return
editor
.chain()
.focus()
.updateDocsGraphic({
graphicType: "draw",
drawScene: payload.drawScene,
src: payload.src,
width: payload.width,
height: payload.height,
lockAspectRatio: true,
})
.run()
notifyDocsGraphicDrawSaved()
handleClose()
}
const handleClose = () => {
setApi(null)
onClose()
}
return (
<Dialog open={open} onOpenChange={(next) => !next && handleClose()}>
<DialogContent
overlayClassName={DOCS_GRAPHIC_DRAW_DIALOG_OVERLAY}
className={cn(
DOCS_GRAPHIC_DRAW_DIALOG_CONTENT,
"flex h-[min(88vh,900px)] max-w-[min(96vw,1200px)] flex-col gap-0 overflow-hidden p-0"
)}
>
<DialogHeader className="shrink-0 border-b border-border px-4 py-3">
<DialogTitle className="flex items-center gap-2 text-base">
<Shapes className="size-4" />
Modifier le dessin
</DialogTitle>
<DialogDescription className="sr-only">
Éditeur vectoriel pour créer et modifier un dessin dans le document.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-hidden">
{open ? (
<DocsExcalidrawEditor
key={editorKey}
drawScene={drawScene}
onReady={setApi}
/>
) : null}
</div>
<DialogFooter className="shrink-0 border-t border-border px-4 py-3 sm:justify-end">
<Button type="button" variant="outline" onClick={handleClose} disabled={saving}>
Annuler
</Button>
<Button type="button" onClick={() => void handleValidate()} disabled={saving || !api}>
{saving ? "Export…" : "Valider"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export const DocsGraphicDrawModal = memo(DocsGraphicDrawModalInner)

View File

@ -0,0 +1,488 @@
"use client"
import { memo, useCallback, useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import type { Editor } from "@tiptap/react"
import { Icon } from "@iconify/react"
import {
ArrowDown,
ArrowUp,
Check,
ChevronDown,
Crop,
MoreHorizontal,
Settings2,
Shapes,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { DOCS_GRAPHIC_WRAP_MARGIN_PRESETS_MM } from "@/lib/drive/docs-graphic-layout"
import { usesPageLayer } from "@/lib/drive/docs-graphic-position"
import {
DOCS_GRAPHIC_WRAP_LABELS,
parseGraphicAttrs,
type DocsGraphicFloatSide,
type DocsGraphicPositionMode,
type DocsGraphicWrap,
} from "@/lib/drive/docs-graphic-types"
import {
DOCS_GRAPHIC_ADVANCED_WRAP_ACTIONS,
DOCS_GRAPHIC_FLOATING_WRAP_ACTIONS,
DOCS_GRAPHIC_FLOAT_SIDE_ICONS,
DOCS_GRAPHIC_FLOAT_SIDE_LABELS,
DOCS_GRAPHIC_LAYER_WRAP_ACTIONS,
DOCS_GRAPHIC_POSITION_MODE_SHORT_LABELS,
DOCS_GRAPHIC_WRAP_ICONS,
} from "@/lib/drive/docs-graphic-ui"
import { openDocsGraphicDrawEditor } from "@/lib/drive/docs-graphic-draw-bridge"
import { readGraphicToolbarActive } from "@/components/drive/richtext/docs-graphic-toolbar-menu"
import { openDocsGraphicOptionsSidebar } from "@/components/drive/richtext/docs-graphic-options-sidebar"
import {
applyDocsGraphicCrop,
DOCS_GRAPHIC_CROP_CHANGED_EVENT,
readDocsGraphicCropActive,
startDocsGraphicCrop,
} from "@/lib/drive/docs-graphic-crop-bridge"
import { cn } from "@/lib/utils"
function readSelectedGraphic(editor: Editor) {
const name = editor.isActive("docsInlineGraphic")
? "docsInlineGraphic"
: editor.isActive("docsGraphic")
? "docsGraphic"
: null
if (!name) return null
return {
name,
attrs: parseGraphicAttrs(editor.getAttributes(name) as Record<string, unknown>),
}
}
function ToolbarDivider() {
return <span className="mx-0.5 h-6 w-px shrink-0 bg-border" aria-hidden />
}
function keepEditorSelectionOnMenuClose(event: Event) {
event.preventDefault()
}
function IconToolbarButton({
label,
active,
onClick,
children,
}: {
label: string
active?: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"size-8 shrink-0 rounded-full text-popover-foreground hover:bg-accent hover:text-accent-foreground",
active && "bg-accent text-primary hover:bg-accent hover:text-primary"
)}
aria-label={label}
title={label}
onClick={onClick}
>
{children}
</Button>
)
}
function DocsGraphicFloatingToolbarInner({
editor,
canvasRef,
disabled,
}: {
editor: Editor | null
canvasRef: React.RefObject<HTMLElement | null>
disabled?: boolean
}) {
const [rect, setRect] = useState<DOMRect | null>(null)
const [cropActive, setCropActive] = useState(false)
const [, setTick] = useState(0)
const replaceInputRef = useRef<HTMLInputElement>(null)
const refresh = useCallback(() => {
setTick((t) => t + 1)
if (!editor || !readGraphicToolbarActive(editor)) {
setRect(null)
return
}
const selected = document.querySelector(
".docs-graphic--selected, .ProseMirror-selectednode .docs-graphic"
) as HTMLElement | null
if (!selected) {
setRect(null)
return
}
setRect(selected.getBoundingClientRect())
setCropActive(readDocsGraphicCropActive())
}, [editor])
useEffect(() => {
if (!editor) return
refresh()
editor.on("selectionUpdate", refresh)
editor.on("transaction", refresh)
const canvas = canvasRef.current
canvas?.addEventListener("scroll", refresh, { passive: true })
window.addEventListener("resize", refresh)
window.addEventListener(DOCS_GRAPHIC_CROP_CHANGED_EVENT, refresh)
return () => {
editor.off("selectionUpdate", refresh)
editor.off("transaction", refresh)
canvas?.removeEventListener("scroll", refresh)
window.removeEventListener("resize", refresh)
window.removeEventListener(DOCS_GRAPHIC_CROP_CHANGED_EVENT, refresh)
}
}, [canvasRef, editor, refresh])
if (!editor || disabled || !readGraphicToolbarActive(editor) || !rect) return null
const graphic = readSelectedGraphic(editor)
if (!graphic) return null
const { attrs } = graphic
const isImage = attrs.graphicType === "image"
const isGradient = attrs.graphicType === "gradient"
const isDraw = attrs.graphicType === "draw" || attrs.graphicType === "shape"
const moveWithText = attrs.positionMode === "move-with-text"
const showInFlowWrap = moveWithText
const showPageLayerActions = usesPageLayer(attrs)
const showAlign = moveWithText
const run = (fn: () => void) => fn()
const applyWrap = (wrap: DocsGraphicWrap) => {
run(() => editor.chain().setDocsGraphicWrap(wrap).run())
}
const applyPositionMode = (positionMode: DocsGraphicPositionMode) => {
run(() => editor.chain().setDocsGraphicPositionMode(positionMode).run())
}
const applyMargin = (wrapMarginMm: number) => {
run(() => editor.chain().setDocsGraphicWrapMargin(wrapMarginMm).run())
}
const applyFloatSide = (floatSide: DocsGraphicFloatSide) => {
run(() => editor.chain().setDocsGraphicFloatSide(floatSide).run())
}
const downloadImage = () => {
if (!attrs.src) return
const link = document.createElement("a")
link.href = attrs.src
link.download = "image"
link.click()
}
const toolbar = (
<div
className="docs-graphic-floating-toolbar pointer-events-auto fixed z-200 -translate-x-1/2"
style={{ left: rect.left + rect.width / 2, top: rect.bottom + 8 }}
role="toolbar"
aria-label="Options graphique"
>
<div className="flex items-center gap-0.5 rounded-full border border-border bg-popover px-1 py-0.5 text-popover-foreground shadow-md">
{isImage || isDraw ? (
<Button
type="button"
variant={isImage && cropActive ? "default" : "ghost"}
size="sm"
className={cn(
"h-8 shrink-0 gap-1.5 rounded-full px-2.5 text-xs",
isImage && cropActive
? "bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
: "hover:bg-accent hover:text-accent-foreground"
)}
onClick={() => {
if (isImage) {
if (cropActive) applyDocsGraphicCrop()
else startDocsGraphicCrop()
} else {
openDocsGraphicDrawEditor(editor)
}
}}
>
{isImage ? (
cropActive ? (
<Check className="size-3.5" />
) : (
<Crop className="size-3.5" />
)
) : (
<Shapes className="size-3.5" />
)}
{isImage ? (cropActive ? "Appliquer" : "Ajuster") : "Modifier le dessin"}
</Button>
) : null}
{showAlign ? (
<>
<ToolbarDivider />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 gap-1 rounded-full px-2 text-xs hover:bg-accent hover:text-accent-foreground"
>
<Icon
icon={DOCS_GRAPHIC_FLOAT_SIDE_ICONS[attrs.floatSide]}
className="size-[18px]"
/>
<ChevronDown className="size-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-40"
onCloseAutoFocus={keepEditorSelectionOnMenuClose}
>
{(Object.keys(DOCS_GRAPHIC_FLOAT_SIDE_LABELS) as DocsGraphicFloatSide[]).map(
(side) => (
<DropdownMenuItem key={side} onClick={() => applyFloatSide(side)}>
<Icon icon={DOCS_GRAPHIC_FLOAT_SIDE_ICONS[side]} className="mr-2 size-4" />
{DOCS_GRAPHIC_FLOAT_SIDE_LABELS[side]}
{attrs.floatSide === side ? " ✓" : ""}
</DropdownMenuItem>
)
)}
</DropdownMenuContent>
</DropdownMenu>
</>
) : null}
{showInFlowWrap ? (
<>
<ToolbarDivider />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 max-w-[140px] shrink-0 gap-1 truncate rounded-full px-2 text-xs hover:bg-accent hover:text-accent-foreground"
>
<Icon icon="material-symbols:wrap-text" className="size-[18px] shrink-0" />
<span className="truncate">
{DOCS_GRAPHIC_WRAP_LABELS[attrs.wrap] ?? "Habillage"}
</span>
<ChevronDown className="size-3 shrink-0 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-48"
onCloseAutoFocus={keepEditorSelectionOnMenuClose}
>
{DOCS_GRAPHIC_FLOATING_WRAP_ACTIONS.map(({ wrap, label }) => (
<DropdownMenuItem key={wrap} onClick={() => applyWrap(wrap)}>
<Icon icon={DOCS_GRAPHIC_WRAP_ICONS[wrap]} className="mr-2 size-4" />
{label}
{attrs.wrap === wrap ? " ✓" : ""}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{DOCS_GRAPHIC_ADVANCED_WRAP_ACTIONS.map(({ wrap, label }) => (
<DropdownMenuItem key={wrap} onClick={() => applyWrap(wrap)}>
<Icon icon={DOCS_GRAPHIC_WRAP_ICONS[wrap]} className="mr-2 size-4" />
{label}
{attrs.wrap === wrap ? " ✓" : ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</>
) : null}
{showPageLayerActions ? (
<>
<ToolbarDivider />
{DOCS_GRAPHIC_LAYER_WRAP_ACTIONS.map(({ wrap, label }) => (
<IconToolbarButton
key={wrap}
label={label}
active={attrs.wrap === wrap}
onClick={() => applyWrap(wrap)}
>
<Icon icon={DOCS_GRAPHIC_WRAP_ICONS[wrap]} className="size-[18px]" />
</IconToolbarButton>
))}
</>
) : null}
<ToolbarDivider />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 gap-1 rounded-full px-2 text-xs hover:bg-accent hover:text-accent-foreground"
>
{attrs.wrapMarginMm}mm
<ChevronDown className="size-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
className="min-w-40"
onCloseAutoFocus={keepEditorSelectionOnMenuClose}
>
{DOCS_GRAPHIC_WRAP_MARGIN_PRESETS_MM.map((mm) => (
<DropdownMenuItem key={mm} onClick={() => applyMargin(mm)}>
Marge de {mm}mm{attrs.wrapMarginMm === mm ? " ✓" : ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 gap-1 rounded-full px-2 text-xs hover:bg-accent hover:text-accent-foreground"
>
<Icon icon="material-symbols:pin" className="size-[18px]" />
{DOCS_GRAPHIC_POSITION_MODE_SHORT_LABELS[attrs.positionMode]}
<ChevronDown className="size-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-48"
onCloseAutoFocus={keepEditorSelectionOnMenuClose}
>
{(["move-with-text", "fixed-on-page"] as DocsGraphicPositionMode[]).map((mode) => (
<DropdownMenuItem key={mode} onClick={() => applyPositionMode(mode)}>
{DOCS_GRAPHIC_POSITION_MODE_SHORT_LABELS[mode]}
{attrs.positionMode === mode ? " ✓" : ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{showPageLayerActions ? (
<>
<IconToolbarButton
label="Avancer"
onClick={() => run(() => editor.chain().bringDocsGraphicForward().run())}
>
<ArrowUp className="size-4" />
</IconToolbarButton>
<IconToolbarButton
label="Reculer"
onClick={() => run(() => editor.chain().sendDocsGraphicBackward().run())}
>
<ArrowDown className="size-4" />
</IconToolbarButton>
<ToolbarDivider />
</>
) : null}
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 gap-1.5 rounded-full px-2.5 text-xs hover:bg-accent hover:text-accent-foreground"
onClick={() =>
openDocsGraphicOptionsSidebar(
isImage ? undefined : isGradient ? "gradient" : undefined
)
}
>
<Settings2 className="size-3.5" />
{isImage
? "Options de l'image"
: isGradient
? "Options du dégradé"
: "Options"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 rounded-full hover:bg-accent hover:text-accent-foreground"
aria-label="Plus d'options"
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-48"
onCloseAutoFocus={keepEditorSelectionOnMenuClose}
>
{isImage ? (
<>
<DropdownMenuItem
onClick={() => {
const alt = window.prompt("Texte alternatif", attrs.alt)
if (alt != null) run(() => editor.chain().updateDocsGraphic({ alt }).run())
}}
>
Texte alternatif
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => replaceInputRef.current?.click()}>
Remplacer l&apos;image
</DropdownMenuItem>
<DropdownMenuItem onClick={downloadImage} disabled={!attrs.src}>
Télécharger l&apos;image
</DropdownMenuItem>
</>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => run(() => editor.chain().focus().deleteSelection().run())}
>
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<input
ref={replaceInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
run(() =>
editor.chain().focus().updateDocsGraphic({ src: reader.result as string }).run()
)
}
reader.readAsDataURL(file)
event.target.value = ""
}}
/>
</div>
</div>
)
return createPortal(toolbar, document.body)
}
export const DocsGraphicFloatingToolbar = memo(DocsGraphicFloatingToolbarInner)

View File

@ -0,0 +1,183 @@
"use client"
import type { DocsGraphicPositionMode, DocsGraphicWrap } from "@/lib/drive/docs-graphic-types"
import { cn } from "@/lib/utils"
function PreviewFrame({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<div
className={cn(
"docs-graphic-layout-preview relative overflow-hidden rounded border border-muted-foreground/25 bg-background",
className
)}
aria-hidden
>
{children}
</div>
)
}
function TextLines({
count = 3,
className,
inset,
}: {
count?: number
className?: string
inset?: string
}) {
return (
<div className={cn("flex flex-col gap-[3px]", inset, className)}>
{Array.from({ length: count }).map((_, i) => (
<span
key={i}
className="block h-[3px] rounded-full bg-muted-foreground/35"
style={{ width: i === count - 1 ? "72%" : "100%" }}
/>
))}
</div>
)
}
function ImageBlock({ className }: { className?: string }) {
return (
<span
className={cn(
"block shrink-0 rounded-[2px] border border-primary/50 bg-primary/25",
className
)}
/>
)
}
export function DocsGraphicWrapPreview({ wrap }: { wrap: DocsGraphicWrap }) {
switch (wrap) {
case "inline":
return (
<PreviewFrame className="flex h-11 items-center gap-1 px-1.5">
<TextLines count={1} className="w-[22%]" />
<ImageBlock className="size-4" />
<TextLines count={1} className="flex-1" />
</PreviewFrame>
)
case "square":
return (
<PreviewFrame className="flex h-11 gap-1.5 p-1.5">
<ImageBlock className="h-full w-[34%]" />
<TextLines count={3} className="flex-1 pt-0.5" />
</PreviewFrame>
)
case "tight":
return (
<PreviewFrame className="flex h-11 gap-0.5 p-1">
<ImageBlock className="h-full w-[30%]" />
<TextLines count={4} className="flex-1 gap-[2px] pt-0" />
</PreviewFrame>
)
case "through":
return (
<PreviewFrame className="h-11 p-1.5">
<ImageBlock className="absolute inset-1.5 opacity-45" />
<TextLines count={3} className="relative z-10 h-full justify-center" />
</PreviewFrame>
)
case "top-bottom":
return (
<PreviewFrame className="flex h-11 flex-col gap-1 p-1.5">
<ImageBlock className="h-[42%] w-full" />
<TextLines count={2} className="flex-1" />
</PreviewFrame>
)
case "behind":
return (
<PreviewFrame className="h-11 p-1.5">
<ImageBlock className="absolute inset-1.5 opacity-35" />
<TextLines count={3} className="relative z-10 h-full justify-center" />
</PreviewFrame>
)
case "in-front":
return (
<PreviewFrame className="h-11 p-1.5">
<TextLines count={3} className="absolute inset-x-1.5 top-2" />
<ImageBlock className="absolute bottom-1.5 left-1/2 z-10 h-[52%] w-[46%] -translate-x-1/2 opacity-90" />
</PreviewFrame>
)
default:
return <PreviewFrame className="h-11" />
}
}
export function DocsGraphicPositionModePreview({
mode,
}: {
mode: DocsGraphicPositionMode
}) {
if (mode === "move-with-text") {
return (
<PreviewFrame className="flex h-11 flex-col justify-center gap-1 p-1.5">
<div className="flex items-center gap-1">
<TextLines count={1} className="w-[28%]" />
<ImageBlock className="size-4" />
<TextLines count={1} className="flex-1" />
</div>
<TextLines count={1} className="w-full" />
<TextLines count={1} className="w-[80%]" />
</PreviewFrame>
)
}
return (
<PreviewFrame className="h-11 p-1">
<span className="absolute inset-1 rounded border border-dashed border-muted-foreground/30" />
<ImageBlock className="absolute right-2 top-2 h-[38%] w-[32%]" />
<TextLines count={2} className="absolute bottom-2 left-2 right-2" />
</PreviewFrame>
)
}
export function DocsGraphicMarginPreview({ mm }: { mm: number }) {
const gap = mm === 0 ? 1 : mm <= 3 ? 3 : mm <= 6 ? 5 : 8
return (
<PreviewFrame className="flex h-11 items-start gap-0 p-1.5">
<div className="flex h-full items-start" style={{ gap: `${gap}px` }}>
<ImageBlock className="h-[70%] w-[38%]" />
<TextLines count={3} className="w-[52%] pt-0.5" />
</div>
</PreviewFrame>
)
}
export function DocsGraphicPageAnchorPreview({
h,
v,
active,
}: {
h: 0 | 0.5 | 1
v: 0 | 0.5 | 1
active?: boolean
}) {
return (
<span
className={cn(
"flex size-9 rounded-md border p-1",
active
? "border-primary bg-primary/5"
: "border-border bg-background hover:border-muted-foreground/50"
)}
>
<span
className={cn(
"relative flex size-full rounded-[2px] border border-muted-foreground/30",
h === 0 && "justify-start",
h === 0.5 && "justify-center",
h === 1 && "justify-end",
v === 0 && "items-start",
v === 0.5 && "items-center",
v === 1 && "items-end"
)}
>
<span className="size-2 rounded-[1px] bg-primary/70" />
</span>
</span>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -81,9 +81,11 @@ export function DocsGraphicOptionsPanel({
<p className="text-sm font-medium">
{attrs.graphicType === "image"
? "Options image"
: attrs.graphicType === "shape"
? "Options forme"
: "Options dégradé"}
: attrs.graphicType === "draw"
? "Options dessin"
: attrs.graphicType === "shape"
? "Options forme"
: "Options dégradé"}
</p>
<div className="grid grid-cols-2 gap-2">
@ -174,6 +176,41 @@ export function DocsGraphicOptionsPanel({
{attrs.graphicType === "gradient" ? (
<div className="grid grid-cols-2 gap-2">
<div className="grid gap-1">
<Label className="text-xs text-muted-foreground">Type</Label>
<Select
disabled={disabled}
value={attrs.gradientType}
onValueChange={(v) => {
const gradientType = v as "linear" | "radial"
update({
gradientType,
gradientCss: buildGradientCss(
attrs.gradientAngle,
attrs.gradientColor1,
attrs.gradientColor2,
gradientType
),
})
}}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="linear">Linéaire</SelectItem>
<SelectItem value="radial">Radial</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label className="text-xs text-muted-foreground">Aperçu</Label>
<div
className="h-8 rounded-md border border-border"
style={{ background: attrs.gradientCss }}
aria-hidden
/>
</div>
<div className="grid gap-1">
<Label className="text-xs text-muted-foreground">Couleur 1</Label>
<Input
@ -188,7 +225,8 @@ export function DocsGraphicOptionsPanel({
gradientCss: buildGradientCss(
attrs.gradientAngle,
gradientColor1,
attrs.gradientColor2
attrs.gradientColor2,
attrs.gradientType
),
})
}}
@ -208,32 +246,36 @@ export function DocsGraphicOptionsPanel({
gradientCss: buildGradientCss(
attrs.gradientAngle,
attrs.gradientColor1,
gradientColor2
gradientColor2,
attrs.gradientType
),
})
}}
/>
</div>
<div className="col-span-2 grid gap-1">
<Label className="text-xs text-muted-foreground">Angle (°)</Label>
<Input
type="number"
className="h-8"
disabled={disabled}
value={attrs.gradientAngle}
onChange={(e) => {
const gradientAngle = Number(e.target.value) || 0
update({
gradientAngle,
gradientCss: buildGradientCss(
{attrs.gradientType === "linear" ? (
<div className="col-span-2 grid gap-1">
<Label className="text-xs text-muted-foreground">Angle (°)</Label>
<Input
type="number"
className="h-8"
disabled={disabled}
value={attrs.gradientAngle}
onChange={(e) => {
const gradientAngle = Number(e.target.value) || 0
update({
gradientAngle,
attrs.gradientColor1,
attrs.gradientColor2
),
})
}}
/>
</div>
gradientCss: buildGradientCss(
gradientAngle,
attrs.gradientColor1,
attrs.gradientColor2,
attrs.gradientType
),
})
}}
/>
</div>
) : null}
</div>
) : null}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,60 @@
"use client"
import { memo, useSyncExternalStore } from "react"
import { DOCS_PAGE_GAP_PX } from "@/lib/drive/docs-page-layout-constants"
import {
getDocsGraphicSnapGuideState,
subscribeDocsGraphicSnapGuides,
} from "@/lib/drive/docs-graphic-snap-bridge"
function DocsGraphicSnapGuidesInner({
pageWidth,
pageHeight,
}: {
pageWidth: number
pageHeight: number
}) {
const state = useSyncExternalStore(
subscribeDocsGraphicSnapGuides,
getDocsGraphicSnapGuideState,
() => null
)
if (!state || state.guides.length === 0) return null
const pageTop = state.pageIndex * (pageHeight + DOCS_PAGE_GAP_PX)
return (
<div
className="pointer-events-none absolute left-0 top-0 z-[24]"
style={{ width: pageWidth, height: pageTop + pageHeight }}
aria-hidden
>
{state.guides.map((guide, index) =>
guide.axis === "x" ? (
<div
key={`v-${guide.position}-${index}`}
className="docs-graphic-snap-guide docs-graphic-snap-guide--vertical"
style={{
left: guide.position,
top: pageTop,
height: pageHeight,
}}
/>
) : (
<div
key={`h-${guide.position}-${index}`}
className="docs-graphic-snap-guide docs-graphic-snap-guide--horizontal"
style={{
top: pageTop + guide.position,
left: 0,
width: pageWidth,
}}
/>
)
)}
</div>
)
}
export const DocsGraphicSnapGuides = memo(DocsGraphicSnapGuidesInner)

View File

@ -1,8 +1,9 @@
"use client"
import { useRef } from "react"
import { useRef, useState } from "react"
import type { Editor } from "@tiptap/react"
import { Icon } from "@iconify/react"
import { ImageIcon, Pencil, Sparkles } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
@ -14,7 +15,12 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { buildInsertGraphicAttrs } from "@/lib/drive/extensions/docs-graphic"
import { DocsDriveImagePickerDialog } from "@/components/drive/richtext/docs-drive-image-picker-dialog"
import { openDocsGraphicDrawModal } from "@/components/drive/richtext/docs-graphic-draw-modal"
import {
buildImageInsertGraphicAttrs,
buildInsertGraphicAttrs,
} from "@/lib/drive/extensions/docs-graphic"
import {
DOCS_GRAPHIC_PLACEMENT_LABELS,
DOCS_GRAPHIC_WRAP_LABELS,
@ -23,6 +29,7 @@ import {
type DocsGraphicWrap,
parseGraphicAttrs,
} from "@/lib/drive/docs-graphic-types"
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
function readSelectedGraphicAttrs(editor: Editor) {
if (editor.isActive("docsGraphic")) {
@ -42,26 +49,32 @@ export function DocsGraphicInsertMenu({
disabled?: boolean
}) {
const imageInputRef = useRef<HTMLInputElement>(null)
const [drivePickerOpen, setDrivePickerOpen] = useState(false)
if (!editor) return null
const insertImage = (file: File, options?: { wrap?: DocsGraphicWrap; placement?: DocsGraphicPlacement }) => {
const insertImageSrc = (
src: string,
options?: { wrap?: DocsGraphicWrap; placement?: DocsGraphicPlacement; alt?: string }
) => {
void buildImageInsertGraphicAttrs({
src,
alt: options?.alt ?? "",
wrap: options?.wrap ?? "square",
placement: options?.placement ?? "block",
}).then((attrs) => {
editor.chain().focus().insertDocsGraphic(attrs).run()
})
}
const insertImageFile = (
file: File,
options?: { wrap?: DocsGraphicWrap; placement?: DocsGraphicPlacement }
) => {
const reader = new FileReader()
reader.onload = () => {
const src = reader.result as string
editor
.chain()
.focus()
.insertDocsGraphic(
buildInsertGraphicAttrs("image", {
src,
wrap: options?.wrap ?? "square",
placement: options?.placement ?? "block",
width: 280,
height: 180,
})
)
.run()
insertImageSrc(src, options)
}
reader.readAsDataURL(file)
}
@ -81,35 +94,35 @@ export function DocsGraphicInsertMenu({
<Icon icon="material-symbols:image-outline" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-56">
<DropdownMenuItem onClick={() => imageInputRef.current?.click()}>
Image
<DropdownMenuContent align="start" className="min-w-64">
<DropdownMenuItem onClick={() => setDrivePickerOpen(true)}>
<img
src={suitePublicAsset("/ultidrive-mark.svg")}
alt=""
className="size-4 shrink-0"
aria-hidden
/>
Image depuis Drive
</DropdownMenuItem>
<DropdownMenuItem onClick={() => imageInputRef.current?.click()}>
<ImageIcon className="size-4 shrink-0" />
Image depuis l&apos;ordinateur
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
editor
.chain()
.focus()
.insertDocsGraphic(
buildInsertGraphicAttrs("draw", { width: 320, height: 240 })
)
.run()
openDocsGraphicDrawModal()
}}
>
<Pencil className="size-4 shrink-0" />
Dessin
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Forme</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{(["rect", "ellipse", "line", "arrow"] as const).map((shapeType) => (
<DropdownMenuItem
key={shapeType}
onClick={() =>
editor
.chain()
.focus()
.insertDocsGraphic(buildInsertGraphicAttrs("shape", { shapeType }))
.run()
}
>
{shapeType === "rect"
? "Rectangle"
: shapeType === "ellipse"
? "Ellipse"
: shapeType === "line"
? "Ligne"
: "Flèche"}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem
onClick={() =>
editor
@ -119,10 +132,20 @@ export function DocsGraphicInsertMenu({
.run()
}
>
<Sparkles className="size-4 shrink-0" />
Dégradé
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DocsDriveImagePickerDialog
open={drivePickerOpen}
onOpenChange={setDrivePickerOpen}
onPickImage={(src, file) => {
insertImageSrc(src, { alt: file.name })
}}
/>
<input
ref={imageInputRef}
type="file"
@ -130,7 +153,7 @@ export function DocsGraphicInsertMenu({
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) insertImage(file)
if (file) insertImageFile(file)
event.target.value = ""
}}
/>
@ -150,7 +173,7 @@ export function DocsGraphicLayoutMenu({
if (!attrs) return null
const applyWrap = (wrap: DocsGraphicWrap) => {
editor.chain().focus().setDocsGraphicWrap(wrap).run()
editor.chain().setDocsGraphicWrap(wrap).run()
}
const applyPlacement = (placement: DocsGraphicPlacement) => {
editor.chain().focus().setDocsGraphicPlacement(placement).run()
@ -173,10 +196,14 @@ export function DocsGraphicLayoutMenu({
<Icon icon="material-symbols:layers-outline" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-64">
<DropdownMenuContent
align="start"
className="min-w-64"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Habillage texte</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuSubContent onCloseAutoFocus={(event) => event.preventDefault()}>
{(Object.keys(DOCS_GRAPHIC_WRAP_LABELS) as DocsGraphicWrap[]).map((wrap) => (
<DropdownMenuItem key={wrap} onClick={() => applyWrap(wrap)}>
{DOCS_GRAPHIC_WRAP_LABELS[wrap]}

View File

@ -84,7 +84,7 @@ function DocsHorizontalRulerInner({
{ticks.map((tick, index) => (
<div
key={`${tick.pos}-${index}`}
className="pointer-events-none absolute bottom-0 w-px bg-[#80868b] dark:bg-muted-foreground/70"
className="pointer-events-none absolute bottom-0 w-px bg-[#bdc1c6] dark:bg-muted-foreground/70"
style={{
left: s(tick.pos),
height: tick.major ? 10 : 5,
@ -97,7 +97,7 @@ function DocsHorizontalRulerInner({
.map((tick) => (
<span
key={`label-${tick.pos}`}
className="pointer-events-none absolute top-[2px] -translate-x-1/2 text-[9px] leading-none text-[#5f6368] dark:text-muted-foreground"
className="pointer-events-none absolute top-[2px] -translate-x-1/2 text-[9px] leading-none text-[#9aa0a6] dark:text-muted-foreground"
style={{ left: s(tick.pos) }}
>
{tick.label}

View File

@ -0,0 +1,583 @@
"use client"
import type { ReactNode } from "react"
import { Icon } from "@iconify/react"
import {
BarChart3,
Bookmark,
Calendar,
CircleDot,
FileText,
ImageIcon,
Link2,
MessageSquarePlus,
Minus,
PenLine,
Plus,
Shapes,
Smile,
Table2,
Upload,
} from "lucide-react"
import { DocsExclusiveMenuSub, DocsExclusiveMenuSubRoot } from "@/components/drive/richtext/docs-exclusive-menu-sub"
import { DocsInsertTablePicker } from "@/components/drive/richtext/docs-insert-table-picker"
import { DOCS_MENUBAR_CONTENT_PROPS } from "@/components/drive/richtext/docs-menubar-props"
import {
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarShortcut,
MenubarSubContent,
MenubarSubTrigger,
MenubarTrigger,
} from "@/components/ui/menubar"
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
export type DocsInsertMenuActions = {
onInsertImageFromComputer: () => void
onInsertImageFromDrive: () => void
onInsertNewDraw: () => void
onInsertDrawFromDrive: () => void
onInsertLink: () => void
onInsertHorizontalRule: () => void
onInsertTable: (rows: number, cols: number) => void
onInsertHeader: () => void
onInsertFooter: () => void
onInsertWatermark: () => void
onInsertPageNumbersHeader: () => void
onInsertPageNumbersFooter: () => void
onOpenPageNumbersOptions: () => void
}
function MenuIcon({ children }: { children: ReactNode }) {
return <span className="docs-menu-item-icon">{children}</span>
}
function DocsMenuBadge({ children }: { children: ReactNode }) {
return (
<span className="docs-menu-badge ml-auto shrink-0 rounded px-1.5 py-px text-[10px] font-medium leading-tight text-white">
{children}
</span>
)
}
function DisabledMenuItem({
icon,
children,
badge,
shortcut,
}: {
icon: ReactNode
children: ReactNode
badge?: ReactNode
shortcut?: string
}) {
return (
<MenubarItem className="docs-menu-item" disabled>
<MenuIcon>{icon}</MenuIcon>
{children}
{badge}
{shortcut ? <MenubarShortcut>{shortcut}</MenubarShortcut> : null}
</MenubarItem>
)
}
function InsertBuildingBlockItems() {
return (
<>
<DisabledMenuItem icon={<Calendar className="size-4" />}>Notes de réunion</DisabledMenuItem>
<DisabledMenuItem icon={<Icon icon="material-symbols:mail-outline" className="size-4" />}>
Brouillon d&apos;email
</DisabledMenuItem>
<DisabledMenuItem icon={<Icon icon="material-symbols:format-list-bulleted" className="size-4" />}>
Journal simple des décisions
</DisabledMenuItem>
<MenubarSeparator />
<DisabledMenuItem icon={<Icon icon="material-symbols:more-vert" className="size-4" />}>
Voir plus
</DisabledMenuItem>
</>
)
}
function InsertPlaceholderChipItems() {
return (
<>
<DisabledMenuItem icon={<Calendar className="size-4" />}>Date</DisabledMenuItem>
<DisabledMenuItem icon={<Icon icon="material-symbols:person-outline" className="size-4" />}>
Personne
</DisabledMenuItem>
<DisabledMenuItem icon={<FileText className="size-4" />}>Fichier</DisabledMenuItem>
<DisabledMenuItem icon={<Icon icon="material-symbols:event-outline" className="size-4" />}>
Événement d&apos;agenda
</DisabledMenuItem>
<DisabledMenuItem icon={<Icon icon="material-symbols:location-on-outline" className="size-4" />}>
Lieu
</DisabledMenuItem>
</>
)
}
export function DocsInsertMenu({
actions,
disabled,
pageElementsEnabled = true,
}: {
actions: DocsInsertMenuActions
disabled?: boolean
pageElementsEnabled?: boolean
}) {
const pageElementsDisabled = disabled || !pageElementsEnabled
return (
<MenubarMenu>
<MenubarTrigger className="docs-menu-trigger">Insertion</MenubarTrigger>
<MenubarContent
{...DOCS_MENUBAR_CONTENT_PROPS}
className="docs-menu-content min-w-[300px] overflow-visible"
data-docs-menu-surface
>
<DocsExclusiveMenuSubRoot>
<DocsExclusiveMenuSub id="insert-image">
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
<MenuIcon>
<ImageIcon className="size-4" />
</MenuIcon>
Image
</MenubarSubTrigger>
<MenubarSubContent
className="docs-menu-content docs-menu-sub-content min-w-[260px] overflow-visible"
data-docs-menu-surface
>
<MenubarItem
className="docs-menu-item"
disabled={disabled}
onClick={actions.onInsertImageFromComputer}
>
<MenuIcon>
<Upload className="size-4" />
</MenuIcon>
Importer depuis l&apos;ordinateur
</MenubarItem>
<DisabledMenuItem icon={<Icon icon="material-symbols:search" className="size-4" />}>
Rechercher sur le Web
</DisabledMenuItem>
<MenubarSeparator />
<MenubarItem
className="docs-menu-item"
disabled={disabled}
onClick={actions.onInsertImageFromDrive}
>
<MenuIcon>
<img
src={suitePublicAsset("/ultidrive-mark.svg")}
alt=""
className="size-4"
aria-hidden
/>
</MenuIcon>
Drive
</MenubarItem>
<DisabledMenuItem icon={<Icon icon="logos:google-photos" className="size-4" />}>
Photos
</DisabledMenuItem>
<DisabledMenuItem icon={<Icon icon="material-symbols:photo-camera-outline" className="size-4" />}>
Appareil photo
</DisabledMenuItem>
<DisabledMenuItem icon={<Link2 className="size-4" />}>
À partir d&apos;une URL
</DisabledMenuItem>
</MenubarSubContent>
</DocsExclusiveMenuSub>
<DocsExclusiveMenuSub id="insert-table">
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
<MenuIcon>
<Table2 className="size-4" />
</MenuIcon>
Tableau
</MenubarSubTrigger>
<MenubarSubContent
className="docs-menu-content docs-menu-sub-content min-w-[220px] overflow-visible p-0"
data-docs-menu-surface
>
<DocsExclusiveMenuSub id="insert-table-building-blocks">
<MenubarSubTrigger className="docs-menu-item" disabled>
<MenuIcon>
<FileText className="size-4" />
</MenuIcon>
Composants de base
</MenubarSubTrigger>
<MenubarSubContent
className="docs-menu-content docs-menu-sub-content min-w-[260px] overflow-visible"
data-docs-menu-surface
>
<InsertBuildingBlockItems />
</MenubarSubContent>
</DocsExclusiveMenuSub>
<DocsInsertTablePicker
onInsert={(rows, cols) => {
if (disabled) return
actions.onInsertTable(rows, cols)
}}
/>
</MenubarSubContent>
</DocsExclusiveMenuSub>
<DocsExclusiveMenuSub id="insert-building-blocks">
<MenubarSubTrigger className="docs-menu-item">
<MenuIcon>
<Plus className="size-4" />
</MenuIcon>
Composants de base
</MenubarSubTrigger>
<MenubarSubContent
className="docs-menu-content docs-menu-sub-content min-w-[260px] overflow-visible"
data-docs-menu-surface
>
<InsertBuildingBlockItems />
</MenubarSubContent>
</DocsExclusiveMenuSub>
<DocsExclusiveMenuSub id="insert-smart-chips">
<MenubarSubTrigger className="docs-menu-item">
<MenuIcon>
<CircleDot className="size-4" />
</MenuIcon>
Chips intelligents
</MenubarSubTrigger>
<MenubarSubContent
className="docs-menu-content docs-menu-sub-content min-w-[260px] overflow-visible"
data-docs-menu-surface
>
<DisabledMenuItem icon={<Calendar className="size-4" />}>Date</DisabledMenuItem>
<DisabledMenuItem icon={<Icon icon="material-symbols:person-outline" className="size-4" />}>
Contact
</DisabledMenuItem>
<DisabledMenuItem icon={<FileText className="size-4" />}>Fichier</DisabledMenuItem>
<DisabledMenuItem icon={<Icon icon="material-symbols:event-outline" className="size-4" />}>
Événement d&apos;agenda
</DisabledMenuItem>
<DisabledMenuItem icon={<Icon icon="material-symbols:location-on-outline" className="size-4" />}>
Lieu
</DisabledMenuItem>
<DocsExclusiveMenuSub id="insert-placeholder-chips">
<MenubarSubTrigger className="docs-menu-item" disabled>
<MenuIcon>
<CircleDot className="size-4" />
</MenuIcon>
Chips d&apos;espace réservé
</MenubarSubTrigger>
<MenubarSubContent
className="docs-menu-content docs-menu-sub-content min-w-[240px] overflow-visible"
data-docs-menu-surface
>
<InsertPlaceholderChipItems />
</MenubarSubContent>
</DocsExclusiveMenuSub>
<DisabledMenuItem icon={<Icon icon="material-symbols:arrow-drop-down-circle-outline" className="size-4" />}>
Menu déroulant
</DisabledMenuItem>
</MenubarSubContent>
</DocsExclusiveMenuSub>
<DocsExclusiveMenuSub id="insert-esignatures">
<MenubarSubTrigger className="docs-menu-item" disabled>
<MenuIcon>
<PenLine className="size-4" />
</MenuIcon>
Signatures électroniques
<DocsMenuBadge>Premium</DocsMenuBadge>
</MenubarSubTrigger>
<MenubarSubContent
className="docs-menu-content docs-menu-sub-content min-w-[260px] overflow-visible"
data-docs-menu-surface
>
<DisabledMenuItem icon={<PenLine className="size-4" />}>
Insérer un champ de signature
</DisabledMenuItem>
<DisabledMenuItem icon={<Icon icon="material-symbols:draw-outline" className="size-4" />}>
Demander une signature
</DisabledMenuItem>
</MenubarSubContent>
</DocsExclusiveMenuSub>
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onInsertLink}>
<MenuIcon>
<Link2 className="size-4" />
</MenuIcon>
Lien
<MenubarShortcut>K</MenubarShortcut>
</MenubarItem>
<DocsExclusiveMenuSub id="insert-draw">
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
<MenuIcon>
<Shapes className="size-4" />
</MenuIcon>
Dessin
</MenubarSubTrigger>
<MenubarSubContent
className="docs-menu-content docs-menu-sub-content min-w-[220px] overflow-visible"
data-docs-menu-surface
>
<MenubarItem
className="docs-menu-item"
disabled={disabled}
onClick={actions.onInsertNewDraw}
>
<MenuIcon>
<Plus className="size-4" />
</MenuIcon>
Nouveau
</MenubarItem>
<MenubarItem
className="docs-menu-item"
disabled={disabled}
onClick={actions.onInsertDrawFromDrive}
>
<MenuIcon>
<img
src={suitePublicAsset("/ultidrive-mark.svg")}
alt=""
className="size-4"
aria-hidden
/>
</MenuIcon>
À partir de Drive
</MenubarItem>
</MenubarSubContent>
</DocsExclusiveMenuSub>
<DocsExclusiveMenuSub id="insert-chart">
<MenubarSubTrigger className="docs-menu-item">
<MenuIcon>
<BarChart3 className="size-4" />
</MenuIcon>
Graphique
</MenubarSubTrigger>
<MenubarSubContent
className="docs-menu-content docs-menu-sub-content min-w-[200px] overflow-visible"
data-docs-menu-surface
>
<DisabledMenuItem icon={<Icon icon="material-symbols:bar-chart" className="size-4" />}>
Barres
</DisabledMenuItem>
<DisabledMenuItem icon={<Icon icon="material-symbols:insert-chart-outline" className="size-4" />}>
Colonnes
</DisabledMenuItem>
<DisabledMenuItem icon={<Icon icon="material-symbols:show-chart" className="size-4" />}>
Lignes
</DisabledMenuItem>
<DisabledMenuItem icon={<Icon icon="material-symbols:pie-chart-outline" className="size-4" />}>
Secteurs
</DisabledMenuItem>
<MenubarSeparator />
<DisabledMenuItem icon={<Icon icon="logos:google-sheets" className="size-4" />}>
Depuis Sheets
</DisabledMenuItem>
</MenubarSubContent>
</DocsExclusiveMenuSub>
<DocsExclusiveMenuSub id="insert-symbols">
<MenubarSubTrigger className="docs-menu-item">
<MenuIcon>
<Smile className="size-4" />
</MenuIcon>
Symboles
</MenubarSubTrigger>
<MenubarSubContent
className="docs-menu-content docs-menu-sub-content min-w-[200px] overflow-visible"
data-docs-menu-surface
>
<DisabledMenuItem icon={<Smile className="size-4" />}>Emoji</DisabledMenuItem>
<DisabledMenuItem icon={<span className="text-sm font-medium">Ω</span>}>
Caractères spéciaux
</DisabledMenuItem>
<DisabledMenuItem icon={<span className="text-sm font-medium">π²</span>}>
Équation
</DisabledMenuItem>
</MenubarSubContent>
</DocsExclusiveMenuSub>
</DocsExclusiveMenuSubRoot>
<MenubarSeparator />
<DisabledMenuItem icon={<Icon icon="material-symbols:tab" className="size-4" />} shortcut="Maj+F11">
Onglet
</DisabledMenuItem>
<MenubarItem
className="docs-menu-item"
disabled={disabled}
onClick={actions.onInsertHorizontalRule}
>
<MenuIcon>
<Minus className="size-4" />
</MenuIcon>
Ligne horizontale
</MenubarItem>
<DocsExclusiveMenuSub id="insert-break">
<MenubarSubTrigger className="docs-menu-item">
<MenuIcon>
<Icon icon="material-symbols:page-break" className="size-4" />
</MenuIcon>
Saut
</MenubarSubTrigger>
<MenubarSubContent
className="docs-menu-content docs-menu-sub-content min-w-[280px] overflow-visible"
data-docs-menu-surface
>
<DisabledMenuItem
icon={<Icon icon="material-symbols:page-break" className="size-4" />}
shortcut="⌘↵"
>
Saut de page
</DisabledMenuItem>
<DisabledMenuItem icon={<Icon icon="material-symbols:view-column" className="size-4" />}>
Saut de colonne
</DisabledMenuItem>
<DisabledMenuItem icon={<Icon icon="material-symbols:subdirectory-arrow-right" className="size-4" />}>
Saut de section (page suivante)
</DisabledMenuItem>
<DisabledMenuItem icon={<Icon icon="material-symbols:subdirectory-arrow-right" className="size-4" />}>
Saut de section (continu)
</DisabledMenuItem>
</MenubarSubContent>
</DocsExclusiveMenuSub>
<DisabledMenuItem icon={<Bookmark className="size-4" />}>Signet</DisabledMenuItem>
<DocsExclusiveMenuSub id="insert-page-elements">
<MenubarSubTrigger className="docs-menu-item">
<MenuIcon>
<Icon icon="material-symbols:article-outline" className="size-4" />
</MenuIcon>
Éléments de page
<DocsMenuBadge>Mise à jour</DocsMenuBadge>
</MenubarSubTrigger>
<MenubarSubContent
className="docs-menu-content docs-menu-sub-content min-w-[300px] overflow-visible"
data-docs-menu-surface
>
<DocsExclusiveMenuSub id="insert-toc">
<MenubarSubTrigger className="docs-menu-item" disabled>
<MenuIcon>
<Icon icon="material-symbols:list-alt" className="size-4" />
</MenuIcon>
Table des matières
</MenubarSubTrigger>
<MenubarSubContent
className="docs-menu-content docs-menu-sub-content min-w-[280px] overflow-visible"
data-docs-menu-surface
>
<DisabledMenuItem icon={<Icon icon="material-symbols:list-alt" className="size-4" />}>
Table des matières avec liens
</DisabledMenuItem>
<DisabledMenuItem icon={<Icon icon="material-symbols:format-list-bulleted" className="size-4" />}>
Table des matières en texte brut
</DisabledMenuItem>
</MenubarSubContent>
</DocsExclusiveMenuSub>
<MenubarItem
className="docs-menu-item"
disabled={pageElementsDisabled}
onClick={actions.onInsertHeader}
>
<MenuIcon>
<Icon icon="material-symbols:vertical-align-top" className="size-4" />
</MenuIcon>
En-tête
<span className="docs-menu-shortcut-sequence ml-auto text-xs text-[#5f6368] dark:text-muted-foreground">
Ctrl+O Ctrl+H
</span>
</MenubarItem>
<MenubarItem
className="docs-menu-item"
disabled={pageElementsDisabled}
onClick={actions.onInsertFooter}
>
<MenuIcon>
<Icon icon="material-symbols:vertical-align-bottom" className="size-4" />
</MenuIcon>
Pied de page
<span className="docs-menu-shortcut-sequence ml-auto text-xs text-[#5f6368] dark:text-muted-foreground">
Ctrl+O Ctrl+F
</span>
</MenubarItem>
<MenubarItem
className="docs-menu-item"
disabled={pageElementsDisabled}
onClick={actions.onInsertWatermark}
>
<MenuIcon>
<Icon icon="material-symbols:branding-watermark-outline" className="size-4" />
</MenuIcon>
Filigrane
</MenubarItem>
<DocsExclusiveMenuSub id="insert-page-numbers">
<MenubarSubTrigger className="docs-menu-item" disabled={pageElementsDisabled}>
<MenuIcon>
<span className="text-sm font-medium">#</span>
</MenuIcon>
Numéros de page
</MenubarSubTrigger>
<MenubarSubContent
className="docs-menu-content docs-menu-sub-content min-w-[240px] overflow-visible"
data-docs-menu-surface
>
<MenubarItem
className="docs-menu-item"
disabled={pageElementsDisabled}
onClick={actions.onInsertPageNumbersHeader}
>
<MenuIcon>
<Icon icon="material-symbols:vertical-align-top" className="size-4" />
</MenuIcon>
En haut de page
</MenubarItem>
<MenubarItem
className="docs-menu-item"
disabled={pageElementsDisabled}
onClick={actions.onInsertPageNumbersFooter}
>
<MenuIcon>
<Icon icon="material-symbols:vertical-align-bottom" className="size-4" />
</MenuIcon>
En bas de page
</MenubarItem>
<MenubarSeparator />
<MenubarItem
className="docs-menu-item"
disabled={pageElementsDisabled}
onClick={actions.onOpenPageNumbersOptions}
>
Options de numérotation
</MenubarItem>
</MenubarSubContent>
</DocsExclusiveMenuSub>
<DisabledMenuItem
icon={<Icon icon="material-symbols:notes" className="size-4" />}
shortcut="⌘⌥F"
>
Note de bas de page
</DisabledMenuItem>
</MenubarSubContent>
</DocsExclusiveMenuSub>
<MenubarSeparator />
<DisabledMenuItem icon={<MessageSquarePlus className="size-4" />} shortcut="⌘⌥M">
Commentaire
</DisabledMenuItem>
</MenubarContent>
</MenubarMenu>
)
}

View File

@ -0,0 +1,57 @@
"use client"
import { useState } from "react"
import { cn } from "@/lib/utils"
const MAX_ROWS = 8
const MAX_COLS = 10
export function DocsInsertTablePicker({
onInsert,
}: {
onInsert: (rows: number, cols: number) => void
}) {
const [hover, setHover] = useState({ rows: 0, cols: 0 })
const label =
hover.rows > 0 && hover.cols > 0 ? `${hover.cols} x ${hover.rows}` : "Insérer un tableau"
return (
<div
className="px-3 py-2"
onMouseLeave={() => setHover({ rows: 0, cols: 0 })}
>
<div
className="inline-grid gap-0.5"
style={{ gridTemplateColumns: `repeat(${MAX_COLS}, 1fr)` }}
role="grid"
aria-label="Sélecteur de tableau"
>
{Array.from({ length: MAX_ROWS }, (_, rowIndex) =>
Array.from({ length: MAX_COLS }, (_, colIndex) => {
const row = rowIndex + 1
const col = colIndex + 1
const active = row <= hover.rows && col <= hover.cols
return (
<button
key={`${row}-${col}`}
type="button"
className={cn(
"size-4 rounded-sm border border-[#dadce0] bg-white transition-colors",
"hover:border-[#1a73e8]",
active && "border-[#1a73e8] bg-[#d2e3fc]"
)}
aria-label={`${col} colonnes par ${row} lignes`}
onMouseEnter={() => setHover({ rows: row, cols: col })}
onClick={() => onInsert(row, col)}
/>
)
})
)}
</div>
<p className="mt-2 text-center text-xs text-[#5f6368] dark:text-muted-foreground">
{label}
</p>
</div>
)
}

View File

@ -0,0 +1,171 @@
"use client"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
DOCS_DEFAULT_LINE_HEIGHT,
DOCS_LINE_HEIGHT_PRESETS,
} from "@/lib/drive/docs-line-spacing"
import type { DocsParagraphSpacingAttrs } from "@/lib/drive/docs-line-spacing"
import {
DRIVE_BTN_GHOST,
DRIVE_BTN_PRIMARY,
DRIVE_DIALOG_BODY,
DRIVE_DIALOG_CONTENT,
DRIVE_DIALOG_FOOTER,
DRIVE_DIALOG_HEADER,
DRIVE_FIELD_CLASS,
DRIVE_LABEL_CLASS,
DRIVE_TEXT_TITLE,
} from "@/lib/drive/drive-dialog-styles"
import { cn } from "@/lib/utils"
export function DocsLineSpacingDialog({
open,
onOpenChange,
initial,
onApply,
}: {
open: boolean
onOpenChange: (open: boolean) => void
initial: DocsParagraphSpacingAttrs
onApply: (input: Pick<DocsParagraphSpacingAttrs, "lineHeight" | "spaceBeforePt" | "spaceAfterPt">) => void
}) {
const [lineHeightPreset, setLineHeightPreset] = useState<string>("1.15")
const [customLineHeight, setCustomLineHeight] = useState(String(DOCS_DEFAULT_LINE_HEIGHT))
const [spaceBefore, setSpaceBefore] = useState("0")
const [spaceAfter, setSpaceAfter] = useState("0")
useEffect(() => {
if (!open) return
const height = initial.lineHeight ?? DOCS_DEFAULT_LINE_HEIGHT
const preset = DOCS_LINE_HEIGHT_PRESETS.find((item) => Math.abs(item.value - height) < 0.001)
setLineHeightPreset(preset?.id ?? "custom")
setCustomLineHeight(String(height))
setSpaceBefore(String(initial.spaceBeforePt))
setSpaceAfter(String(initial.spaceAfterPt))
}, [initial, open])
const resolvedLineHeight = (): number | null => {
if (lineHeightPreset === "custom") {
const value = Number.parseFloat(customLineHeight)
return Number.isFinite(value) && value > 0 ? value : DOCS_DEFAULT_LINE_HEIGHT
}
const preset = DOCS_LINE_HEIGHT_PRESETS.find((item) => item.id === lineHeightPreset)
return preset?.value ?? DOCS_DEFAULT_LINE_HEIGHT
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn(DRIVE_DIALOG_CONTENT, "max-w-md gap-0 p-0")}>
<DialogHeader className={DRIVE_DIALOG_HEADER}>
<DialogTitle className={cn("text-base font-medium", DRIVE_TEXT_TITLE)}>
Espacement personnalisé
</DialogTitle>
</DialogHeader>
<div className={cn(DRIVE_DIALOG_BODY, "space-y-4")}>
<div className="space-y-2">
<Label className={DRIVE_LABEL_CLASS} htmlFor="docs-line-height-preset">
Interligne
</Label>
<Select value={lineHeightPreset} onValueChange={setLineHeightPreset}>
<SelectTrigger id="docs-line-height-preset" className={DRIVE_FIELD_CLASS}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DOCS_LINE_HEIGHT_PRESETS.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
{preset.label}
</SelectItem>
))}
<SelectItem value="custom">Personnalisé</SelectItem>
</SelectContent>
</Select>
{lineHeightPreset === "custom" ? (
<Input
type="number"
min={0.5}
max={5}
step={0.05}
value={customLineHeight}
onChange={(event) => setCustomLineHeight(event.target.value)}
className={DRIVE_FIELD_CLASS}
aria-label="Interligne personnalisé"
/>
) : null}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label className={DRIVE_LABEL_CLASS} htmlFor="docs-space-before">
Avant (pt)
</Label>
<Input
id="docs-space-before"
type="number"
min={0}
max={108}
step={1}
value={spaceBefore}
onChange={(event) => setSpaceBefore(event.target.value)}
className={DRIVE_FIELD_CLASS}
/>
</div>
<div className="space-y-2">
<Label className={DRIVE_LABEL_CLASS} htmlFor="docs-space-after">
Après (pt)
</Label>
<Input
id="docs-space-after"
type="number"
min={0}
max={108}
step={1}
value={spaceAfter}
onChange={(event) => setSpaceAfter(event.target.value)}
className={DRIVE_FIELD_CLASS}
/>
</div>
</div>
</div>
<DialogFooter className={DRIVE_DIALOG_FOOTER}>
<Button type="button" variant="ghost" className={DRIVE_BTN_GHOST} onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button
type="button"
className={DRIVE_BTN_PRIMARY}
onClick={() => {
onApply({
lineHeight: resolvedLineHeight(),
spaceBeforePt: Math.max(0, Number.parseFloat(spaceBefore) || 0),
spaceAfterPt: Math.max(0, Number.parseFloat(spaceAfter) || 0),
})
onOpenChange(false)
}}
>
Appliquer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,307 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import type { Editor } from "@tiptap/react"
import { Link2, Loader2, Search } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import type { DriveFileInfo } from "@/lib/api/types"
import { useDriveSearchSuggestions } from "@/lib/api/hooks/use-drive-queries"
import { DriveFileTypeIcon } from "@/lib/drive/drive-file-icon"
import { itemLocationLabel } from "@/lib/drive/drive-search"
import { DOCS_LINK_POPOVER_EVENT } from "@/lib/drive/docs-link-bridge"
import { normalizeDocsLinkHref, resolveDriveItemLinkHref } from "@/lib/drive/docs-link-href"
import { displayFileName } from "@/lib/drive/display-file-name"
import { useDebouncedValue } from "@/lib/hooks/use-debounced-value"
import { cn } from "@/lib/utils"
type SavedSelection = { from: number; to: number }
function DocsLinkToolbarBtn({
active,
disabled,
onPrepareOpen,
}: {
active?: boolean
disabled?: boolean
onPrepareOpen: () => void
}) {
return (
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"docs-toolbar-btn size-7 shrink-0",
active && "docs-toolbar-btn--active"
)}
disabled={disabled}
aria-label="Hyperlien"
title="Hyperlien"
aria-pressed={active}
onMouseDown={(event) => {
event.preventDefault()
onPrepareOpen()
}}
>
<Link2 className="size-4" />
</Button>
)
}
function DriveFileSuggestionRow({
item,
disabled,
onPick,
}: {
item: DriveFileInfo
disabled?: boolean
onPick: (item: DriveFileInfo) => void
}) {
return (
<button
type="button"
disabled={disabled}
className="flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent disabled:pointer-events-none disabled:opacity-50"
onMouseDown={(event) => event.preventDefault()}
onClick={() => onPick(item)}
>
<DriveFileTypeIcon file={item} className="size-4 shrink-0" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">{displayFileName(item.name)}</span>
<span className="block truncate text-xs text-muted-foreground">
{itemLocationLabel(item.path, item.type)}
</span>
</span>
</button>
)
}
export function DocsLinkPopover({
editor,
disabled,
active,
}: {
editor: Editor
disabled?: boolean
active?: boolean
}) {
const [open, setOpen] = useState(false)
const [linkUrl, setLinkUrl] = useState("")
const [driveQuery, setDriveQuery] = useState("")
const [resolvingDriveFile, setResolvingDriveFile] = useState(false)
const urlInputRef = useRef<HTMLInputElement>(null)
const selectionRef = useRef<SavedSelection | null>(null)
const debouncedDriveQuery = useDebouncedValue(driveQuery, 250)
const { data: driveResults, isFetching: driveLoading } = useDriveSearchSuggestions(
debouncedDriveQuery,
"all",
"/",
open
)
const driveSuggestions = driveResults?.files ?? []
const hasTextSelection = useCallback(() => {
const sel = selectionRef.current
return Boolean(sel && sel.from !== sel.to)
}, [])
const applyLink = useCallback(
(rawUrl?: string) => {
const url = normalizeDocsLinkHref(rawUrl ?? linkUrl)
const sel = selectionRef.current
let chain = editor.chain().focus()
if (sel && sel.from !== sel.to) {
chain = chain.setTextSelection({ from: sel.from, to: sel.to })
} else if (editor.isActive("link")) {
chain = chain.extendMarkRange("link")
} else {
return
}
if (!url) {
chain.unsetLink().run()
} else {
chain.setLink({ href: url }).run()
}
setOpen(false)
setLinkUrl("")
setDriveQuery("")
selectionRef.current = null
},
[editor, linkUrl]
)
const prepareOpen = useCallback(() => {
const { from, to } = editor.state.selection
selectionRef.current = { from, to }
const prev = editor.getAttributes("link").href as string | undefined
setLinkUrl(prev ?? "")
setDriveQuery("")
}, [editor])
const pickDriveFile = useCallback(
async (file: DriveFileInfo) => {
setResolvingDriveFile(true)
try {
const href = await resolveDriveItemLinkHref(file)
setLinkUrl(href)
applyLink(href)
} catch {
window.alert("Impossible de créer le lien vers ce fichier.")
} finally {
setResolvingDriveFile(false)
}
},
[applyLink]
)
useEffect(() => {
if (!open) return
const id = window.requestAnimationFrame(() => {
urlInputRef.current?.focus()
urlInputRef.current?.select()
})
return () => window.cancelAnimationFrame(id)
}, [open])
useEffect(() => {
const onOpenRequest = () => {
prepareOpen()
setOpen(true)
}
window.addEventListener(DOCS_LINK_POPOVER_EVENT, onOpenRequest)
return () => window.removeEventListener(DOCS_LINK_POPOVER_EVENT, onOpenRequest)
}, [prepareOpen])
const canApply = hasTextSelection() || editor.isActive("link")
const trimmedDriveQuery = driveQuery.trim()
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) {
setLinkUrl("")
setDriveQuery("")
selectionRef.current = null
}
}}
>
<PopoverTrigger asChild>
<DocsLinkToolbarBtn
active={active}
disabled={disabled}
onPrepareOpen={prepareOpen}
/>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start" sideOffset={6}>
<div className="border-b px-3 py-2">
<label htmlFor="docs-link-url" className="mb-1.5 block text-xs font-medium text-muted-foreground">
Adresse du lien
</label>
<div className="flex gap-2">
<input
ref={urlInputRef}
id="docs-link-url"
type="text"
inputMode="url"
value={linkUrl}
placeholder="https:// ou /drive/…"
disabled={disabled || resolvingDriveFile}
className="h-8 min-w-0 flex-1 rounded-md border border-input bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
onChange={(event) => setLinkUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
applyLink()
}
}}
/>
<Button
type="button"
size="sm"
disabled={disabled || !canApply || resolvingDriveFile}
onClick={() => applyLink()}
>
OK
</Button>
</div>
{!canApply ? (
<p className="mt-1.5 text-xs text-muted-foreground">
Sélectionnez du texte pour créer un hyperlien.
</p>
) : null}
</div>
<div className="px-3 py-2">
<label htmlFor="docs-link-drive-search" className="mb-1.5 block text-xs font-medium text-muted-foreground">
Fichier du Drive
</label>
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input
id="docs-link-drive-search"
type="search"
value={driveQuery}
placeholder="Rechercher un fichier…"
disabled={disabled || resolvingDriveFile}
className="h-8 w-full rounded-md border border-input bg-background py-0 pl-7 pr-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
onChange={(event) => setDriveQuery(event.target.value)}
/>
</div>
</div>
<div className="max-h-48 overflow-y-auto border-t px-1 py-1">
{resolvingDriveFile ? (
<div className="flex items-center gap-2 px-2 py-3 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Création du lien
</div>
) : trimmedDriveQuery.length < 2 ? (
<p className="px-2 py-3 text-xs text-muted-foreground">
Saisissez au moins 2 caractères pour rechercher dans votre Drive.
</p>
) : driveLoading ? (
<div className="flex items-center gap-2 px-2 py-3 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Recherche
</div>
) : driveSuggestions.length === 0 ? (
<p className="px-2 py-3 text-sm text-muted-foreground">
Aucun fichier pour « {trimmedDriveQuery} »
</p>
) : (
driveSuggestions.map((item) => (
<DriveFileSuggestionRow
key={item.path}
item={item}
disabled={disabled || !canApply}
onPick={pickDriveFile}
/>
))
)}
</div>
{editor.isActive("link") ? (
<div className="border-t px-2 py-1.5">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-full justify-start text-destructive hover:text-destructive"
disabled={disabled}
onClick={() => applyLink("")}
>
Supprimer le lien
</Button>
</div>
) : null}
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,85 @@
"use client"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
DRIVE_BTN_GHOST,
DRIVE_BTN_PRIMARY,
DRIVE_DIALOG_BODY,
DRIVE_DIALOG_CONTENT,
DRIVE_DIALOG_FOOTER,
DRIVE_DIALOG_HEADER,
DRIVE_FIELD_CLASS,
DRIVE_LABEL_CLASS,
DRIVE_TEXT_TITLE,
} from "@/lib/drive/drive-dialog-styles"
import { cn } from "@/lib/utils"
export function DocsListStartDialog({
open,
onOpenChange,
initialStart,
onApply,
}: {
open: boolean
onOpenChange: (open: boolean) => void
initialStart: number
onApply: (start: number) => void
}) {
const [start, setStart] = useState(String(initialStart))
useEffect(() => {
if (open) setStart(String(initialStart))
}, [initialStart, open])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn(DRIVE_DIALOG_CONTENT, "max-w-sm gap-0 p-0")}>
<DialogHeader className={DRIVE_DIALOG_HEADER}>
<DialogTitle className={cn("text-base font-medium", DRIVE_TEXT_TITLE)}>
Numéro de départ
</DialogTitle>
</DialogHeader>
<div className={DRIVE_DIALOG_BODY}>
<Label className={DRIVE_LABEL_CLASS} htmlFor="docs-list-start">
Commencer la liste à
</Label>
<Input
id="docs-list-start"
type="number"
min={1}
max={9999}
step={1}
value={start}
onChange={(event) => setStart(event.target.value)}
className={cn(DRIVE_FIELD_CLASS, "mt-2")}
/>
</div>
<DialogFooter className={DRIVE_DIALOG_FOOTER}>
<Button type="button" variant="ghost" className={DRIVE_BTN_GHOST} onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button
type="button"
className={DRIVE_BTN_PRIMARY}
onClick={() => {
onApply(Math.max(1, Number.parseInt(start, 10) || 1))
onOpenChange(false)
}}
>
Appliquer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,64 @@
"use client"
import { memo } from "react"
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
import {
docsLoadingPhaseLabel,
type DocsLoadingPhase,
} from "@/lib/drive/docs-loading-phase"
import { cn } from "@/lib/utils"
export function DocsLoadingSplash({
phase = "connecting",
title,
className,
overlay = false,
}: {
phase?: DocsLoadingPhase
title?: string
className?: string
/** Keep the same splash mounted as an overlay while the editor loads underneath. */
overlay?: boolean
}) {
const subtitle = docsLoadingPhaseLabel(phase)
return (
<div
className={cn(
"docs-loading-splash",
overlay && "docs-loading-splash--overlay",
className
)}
role="status"
aria-live="polite"
aria-label={subtitle}
data-drive-app
>
<div className="docs-loading-splash__aurora" aria-hidden />
<div className="docs-loading-splash__grain" aria-hidden />
<div className="docs-loading-splash__content">
<div className="docs-loading-splash__pill">ULTIDOCS</div>
<img
src={suitePublicAsset("/ultidrive-mark.svg")}
alt=""
className="docs-loading-splash__mark"
width={56}
height={56}
decoding="async"
draggable={false}
/>
{title ? (
<p className="docs-loading-splash__title" title={title}>
{title}
</p>
) : null}
<p className="docs-loading-splash__subtitle">{subtitle}</p>
<div className="docs-loading-splash__loader" aria-hidden>
<span />
</div>
</div>
</div>
)
}
export const MemoDocsLoadingSplash = memo(DocsLoadingSplash)

View File

@ -2,6 +2,12 @@
import { DocsEditMenu, type DocsEditMenuActions, type DocsEditMenuState } from "@/components/drive/richtext/docs-edit-menu"
import { DocsFileMenu, type DocsFileMenuActions } from "@/components/drive/richtext/docs-file-menu"
import {
DocsFormatMenu,
type DocsFormatMenuActions,
type DocsFormatMenuState,
} from "@/components/drive/richtext/docs-format-menu"
import { DocsInsertMenu, type DocsInsertMenuActions } from "@/components/drive/richtext/docs-insert-menu"
import { DocsViewMenu, type DocsViewMenuActions, type DocsViewMenuState } from "@/components/drive/richtext/docs-view-menu"
import { DOCS_MENUBAR_CONTENT_PROPS } from "@/components/drive/richtext/docs-menubar-props"
import {
@ -13,7 +19,7 @@ import {
} from "@/components/ui/menubar"
import { cn } from "@/lib/utils"
const OTHER_MENU_LABELS = ["Insertion", "Format", "Outils", "Aide"] as const
const OTHER_MENU_LABELS = ["Outils", "Aide"] as const
export function DocsMenubar({
viewMenuActions,
@ -24,6 +30,12 @@ export function DocsMenubar({
editMenuActions,
editMenuState,
editMenuDisabled,
insertMenuActions,
insertMenuDisabled,
insertMenuPageElementsEnabled,
formatMenuActions,
formatMenuState,
formatMenuDisabled,
className,
}: {
viewMenuActions?: DocsViewMenuActions
@ -34,6 +46,12 @@ export function DocsMenubar({
editMenuActions?: DocsEditMenuActions
editMenuState?: DocsEditMenuState
editMenuDisabled?: boolean
insertMenuActions?: DocsInsertMenuActions
insertMenuDisabled?: boolean
insertMenuPageElementsEnabled?: boolean
formatMenuActions?: DocsFormatMenuActions
formatMenuState?: DocsFormatMenuState
formatMenuDisabled?: boolean
className?: string
}) {
return (
@ -90,6 +108,44 @@ export function DocsMenubar({
</MenubarMenu>
)}
{insertMenuActions ? (
<DocsInsertMenu
actions={insertMenuActions}
disabled={insertMenuDisabled}
pageElementsEnabled={insertMenuPageElementsEnabled}
/>
) : (
<MenubarMenu>
<MenubarTrigger className="docs-menu-trigger">Insertion</MenubarTrigger>
<MenubarContent {...DOCS_MENUBAR_CONTENT_PROPS} data-docs-menu-surface>
<MenubarItem disabled className="text-muted-foreground">
Bientôt disponible
</MenubarItem>
</MenubarContent>
</MenubarMenu>
)}
{formatMenuActions && formatMenuState ? (
<DocsFormatMenu
actions={formatMenuActions}
state={formatMenuState}
disabled={formatMenuDisabled}
/>
) : (
<MenubarMenu>
<MenubarTrigger className="docs-menu-trigger">Format</MenubarTrigger>
<MenubarContent
{...DOCS_MENUBAR_CONTENT_PROPS}
className="docs-menu-content overflow-visible"
data-docs-menu-surface
>
<MenubarItem disabled className="text-muted-foreground">
Bientôt disponible
</MenubarItem>
</MenubarContent>
</MenubarMenu>
)}
{OTHER_MENU_LABELS.map((label) => (
<MenubarMenu key={label}>
<MenubarTrigger className="docs-menu-trigger">{label}</MenubarTrigger>

View File

@ -9,6 +9,10 @@ import {
type DocsHeaderFooterRegion,
} from "@/components/drive/richtext/docs-header-footer-region"
import { DocsBodyMarginMasks } from "@/components/drive/richtext/docs-body-margin-masks"
import {
DOCS_REGION_EDIT_EVENT,
type DocsRegionEditDetail,
} from "@/lib/drive/docs-page-elements-bridge"
import {
DOCS_CANVAS_PADDING_TOP_NARROW_PX,
DOCS_CANVAS_PADDING_Y_PX,
@ -25,8 +29,10 @@ import {
} from "@/lib/drive/docs-page-metrics"
import { docsPageLengthToScreen, docsZoomToScale } from "@/lib/drive/docs-ruler-scale"
import { cn } from "@/lib/utils"
import { focusEditorAtPointer } from "@/lib/drive/focus-editor-at-pointer"
import { DocsGraphicSnapGuides } from "@/components/drive/richtext/docs-graphic-snap-guides"
import { DocsTableContextMenu } from "@/components/drive/richtext/docs-table-context-menu"
import { applyPageFlowLayout, computeSimulatedLayoutHeight, readPageFlowMetrics } from "@/lib/drive/extensions/docs-page-flow-decoration"
import { focusEditorAtPointer } from "@/lib/drive/focus-editor-at-pointer"
/** Total layout height inside ProseMirror (blocks + flow spacers). */
function measureProseContentHeight(prose: HTMLElement): number {
@ -187,6 +193,16 @@ function DocsPageViewInner({
return () => window.removeEventListener("keydown", onKey)
}, [editingTarget, stopRegionEdit])
useEffect(() => {
const onRegionEditRequest = (event: Event) => {
const detail = (event as CustomEvent<DocsRegionEditDetail>).detail
if (!detail?.region) return
startRegionEdit(detail.region, detail.pageIndex ?? 0)
}
window.addEventListener(DOCS_REGION_EDIT_EVENT, onRegionEditRequest)
return () => window.removeEventListener(DOCS_REGION_EDIT_EVENT, onRegionEditRequest)
}, [startRegionEdit])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
@ -351,12 +367,19 @@ function DocsPageViewInner({
}}
>
<div
className="relative mx-auto overflow-hidden"
className="relative mx-auto overflow-visible"
style={{ width: scaledWidth, height: showLayout ? scaledHeight : undefined }}
>
<div
data-docs-page-stack
className="absolute left-1/2 top-0 -translate-x-1/2 overflow-hidden"
data-docs-page-height={pageHeight}
data-docs-page-width={pageWidth}
data-docs-page-scale={scale}
data-docs-page-margin-top={effectiveMargins.top}
data-docs-page-margin-right={effectiveMargins.right}
data-docs-page-margin-bottom={effectiveMargins.bottom}
data-docs-page-margin-left={effectiveMargins.left}
className="absolute left-1/2 top-0 -translate-x-1/2 overflow-visible"
style={{
width: pageWidth,
height: stackHeight,
@ -495,6 +518,20 @@ function DocsPageViewInner({
/>
) : null}
<div
id="docs-page-graphic-layer-behind"
className="pointer-events-none absolute left-0 top-0 z-[12]"
style={{ width: pageWidth, height: stackHeight }}
/>
<div
id="docs-page-graphic-layer-front"
className="pointer-events-none absolute left-0 top-0 z-[18]"
style={{ width: pageWidth, height: stackHeight }}
/>
<DocsGraphicSnapGuides pageWidth={pageWidth} pageHeight={pageHeight} />
<div
ref={contentRef}
className={cn(
@ -532,10 +569,14 @@ function DocsPageViewInner({
focusEditorAtPointer(editor, event.clientX, event.clientY)
}}
>
<EditorContent
editor={editor}
className={cn(!editable && "pointer-events-none select-text")}
/>
<DocsTableContextMenu editor={editor} disabled={!editable || bodyDimmed}>
<div className="min-h-0 min-w-0">
<EditorContent
editor={editor}
className={cn(!editable && "pointer-events-none select-text")}
/>
</div>
</DocsTableContextMenu>
</div>
</div>
</div>
@ -562,7 +603,7 @@ export function DocsStatusBar({
return (
<div
className={cn(
"flex shrink-0 items-center justify-between border-t border-[#dadce0] bg-[#edf2fa] px-4 py-1 text-xs text-[#5f6368] dark:border-border dark:bg-muted/40 dark:text-muted-foreground",
"docs-status-bar flex shrink-0 items-center justify-between border-t border-[#dadce0] bg-[#edf2fa] px-4 py-1 text-xs text-[#5f6368] dark:border-border dark:bg-muted/40 dark:text-muted-foreground",
className
)}
>

View File

@ -0,0 +1,179 @@
"use client"
import { useMemo, useState } from "react"
import {
buildParagraphStyleMenuEntries,
type DocParagraphStyleDefinition,
type DocParagraphStylesCatalog,
} from "@/lib/drive/docs-paragraph-styles"
import { paragraphStylePreviewStyle } from "@/lib/drive/docs-paragraph-styles-css"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
export function DocsParagraphStyleSelect({
value,
disabled,
documentStyles,
userStyles,
onValueChange,
triggerClassName,
}: {
value: string
disabled?: boolean
documentStyles: DocParagraphStylesCatalog
userStyles: DocParagraphStylesCatalog
onValueChange: (styleId: string) => void
triggerClassName?: string
}) {
const entries = useMemo(
() => buildParagraphStyleMenuEntries(documentStyles, userStyles),
[documentStyles, userStyles]
)
const documentEntries = entries.filter((entry) => entry.section === "document")
const userEntries = entries.filter((entry) => entry.section === "user")
const active = entries.find((entry) => entry.definition.id === value)?.definition
return (
<Select disabled={disabled} value={value} onValueChange={onValueChange}>
<SelectTrigger
className={cn(
"docs-toolbar-select h-7 w-[120px] shrink-0 border-0 bg-transparent px-1 shadow-none",
triggerClassName
)}
>
<SelectValue placeholder="Style">{active?.name ?? "Texte normal"}</SelectValue>
</SelectTrigger>
<SelectContent className="docs-toolbar-select-content docs-toolbar-select-content--style max-h-[min(70vh,480px)]">
<SelectGroup>
<SelectLabel>Styles du document</SelectLabel>
{documentEntries.map(({ definition }) => (
<SelectItem
key={definition.id}
value={definition.id}
className="docs-toolbar-style-item"
>
<span className="docs-toolbar-style-preview" style={paragraphStylePreviewStyle(definition)}>
{definition.name}
</span>
</SelectItem>
))}
</SelectGroup>
{userEntries.length > 0 ? (
<>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Mes styles</SelectLabel>
{userEntries.map(({ definition }) => (
<SelectItem
key={`user-${definition.id}`}
value={definition.id}
className="docs-toolbar-style-item"
>
<span
className="docs-toolbar-style-preview"
style={paragraphStylePreviewStyle(definition)}
>
{definition.name}
</span>
</SelectItem>
))}
</SelectGroup>
</>
) : null}
</SelectContent>
</Select>
)
}
export function DocsCreateStyleDialog({
open,
onOpenChange,
onCreate,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onCreate: (input: { name: string; basedOn: string }) => void
}) {
const [name, setName] = useState("")
const [basedOn, setBasedOn] = useState("normal")
return open ? (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/30 p-4">
<div
className="w-full max-w-md rounded-lg border bg-background p-4 shadow-lg"
role="dialog"
aria-modal="true"
aria-labelledby="docs-create-style-title"
>
<h2 id="docs-create-style-title" className="text-base font-medium">
Créer un style
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Le nouveau style sera enregistré dans vos styles personnels.
</p>
<div className="mt-4 space-y-3">
<label className="block space-y-1">
<span className="text-sm">Nom</span>
<input
className="flex h-9 w-full rounded-md border bg-background px-3 text-sm"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Mon style"
/>
</label>
<label className="block space-y-1">
<span className="text-sm">Basé sur</span>
<select
className="flex h-9 w-full rounded-md border bg-background px-3 text-sm"
value={basedOn}
onChange={(event) => setBasedOn(event.target.value)}
>
<option value="normal">Normal</option>
<option value="title">Titre</option>
<option value="subtitle">Sous-titre</option>
<option value="heading1">Titre 1</option>
<option value="heading2">Titre 2</option>
<option value="heading3">Titre 3</option>
<option value="heading4">Titre 4</option>
</select>
</label>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
className="rounded-md px-3 py-1.5 text-sm hover:bg-muted"
onClick={() => onOpenChange(false)}
>
Annuler
</button>
<button
type="button"
className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground"
onClick={() => {
onCreate({ name: name.trim() || "Style personnalisé", basedOn })
setName("")
setBasedOn("normal")
onOpenChange(false)
}}
>
Créer
</button>
</div>
</div>
</div>
) : null
}
export function paragraphStyleSubmenuLabel(definition: DocParagraphStyleDefinition) {
return definition.name
}

View File

@ -19,6 +19,7 @@ export function DocsRulerToolbarRow({
scale,
rulerSync,
rulerTrackRef,
contentInsetRight = 0,
outlineExpanded,
onToggleOutline,
editable,
@ -30,6 +31,8 @@ export function DocsRulerToolbarRow({
scale: number
rulerSync: DocsRulerSyncState
rulerTrackRef: RefObject<HTMLDivElement | null>
/** Right gutter matching panels beside the canvas (e.g. graphic options sidebar). */
contentInsetRight?: number
outlineExpanded?: boolean
onToggleOutline?: () => void
editable?: boolean
@ -70,7 +73,7 @@ export function DocsRulerToolbarRow({
className="relative min-w-0 flex-1 overflow-visible"
style={{
height: DOCS_HORIZONTAL_RULER_HEIGHT_PX,
paddingRight: rulerSync.canvasScrollbarWidth,
paddingRight: rulerSync.canvasScrollbarWidth + contentInsetRight,
}}
>
<div

View File

@ -0,0 +1,246 @@
"use client"
import { useRef, useState } from "react"
import type { Editor } from "@tiptap/react"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { focusEditorAtPointer } from "@/lib/drive/focus-editor-at-pointer"
import {
docsClearTableCellBorders,
docsDefaultTableBorder,
docsSetTableAlignment,
docsSetTableCellBackground,
docsSetTableCellBordersAll,
docsSetTableCellVerticalAlign,
docsSetTableRowHeight,
docsTableCanMerge,
docsTableCanSplit,
} from "@/lib/drive/docs-table-actions"
import {
DOCS_TABLE_BORDER_COLOR_PRESETS,
DOCS_TABLE_CELL_BACKGROUND_PRESETS,
} from "@/lib/drive/docs-table-types"
export function DocsTableContextMenu({
editor,
disabled,
children,
}: {
editor: Editor
disabled?: boolean
children: React.ReactNode
}) {
const pointerRef = useRef<{ x: number; y: number } | null>(null)
const [tableMenuActive, setTableMenuActive] = useState(false)
const refreshTableMenuState = () => {
const point = pointerRef.current
if (point) focusEditorAtPointer(editor, point.x, point.y)
setTableMenuActive(editor.isActive("table"))
}
const canMerge = tableMenuActive && docsTableCanMerge(editor)
const canSplit = tableMenuActive && docsTableCanSplit(editor)
return (
<ContextMenu
onOpenChange={(open) => {
if (open) refreshTableMenuState()
else setTableMenuActive(false)
}}
>
<ContextMenuTrigger
asChild
disabled={disabled}
onContextMenu={(event) => {
pointerRef.current = { x: event.clientX, y: event.clientY }
}}
>
{children}
</ContextMenuTrigger>
<ContextMenuContent
className="min-w-56"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<ContextMenuItem onClick={() => document.execCommand("cut")}>
Couper
</ContextMenuItem>
<ContextMenuItem onClick={() => document.execCommand("copy")}>
Copier
</ContextMenuItem>
<ContextMenuItem onClick={() => document.execCommand("paste")}>
Coller
</ContextMenuItem>
<ContextMenuItem
onClick={() => editor.chain().focus().deleteSelection().run()}
>
Supprimer
</ContextMenuItem>
{tableMenuActive ? (
<>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => editor.chain().focus().addRowBefore().run()}
>
Insérer une ligne au-dessus
</ContextMenuItem>
<ContextMenuItem
onClick={() => editor.chain().focus().addRowAfter().run()}
>
Insérer une ligne en dessous
</ContextMenuItem>
<ContextMenuItem
onClick={() => editor.chain().focus().addColumnBefore().run()}
>
Insérer une colonne à gauche
</ContextMenuItem>
<ContextMenuItem
onClick={() => editor.chain().focus().addColumnAfter().run()}
>
Insérer une colonne à droite
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
disabled={!canMerge}
onClick={() => editor.chain().focus().mergeCells().run()}
>
Fusionner les cellules
</ContextMenuItem>
<ContextMenuItem
disabled={!canSplit}
onClick={() => editor.chain().focus().splitCell().run()}
>
Scinder la cellule
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>Couleur de cellule</ContextMenuSubTrigger>
<ContextMenuSubContent>
{DOCS_TABLE_CELL_BACKGROUND_PRESETS.map((preset) => (
<ContextMenuItem
key={preset.id || "none"}
onClick={() =>
docsSetTableCellBackground(editor, preset.color || null)
}
>
{preset.label}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSub>
<ContextMenuSubTrigger>Bordures</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem
onClick={() =>
docsSetTableCellBordersAll(editor, docsDefaultTableBorder("#000000"))
}
>
Bordures noires
</ContextMenuItem>
<ContextMenuItem onClick={() => docsClearTableCellBorders(editor)}>
Supprimer les bordures
</ContextMenuItem>
{DOCS_TABLE_BORDER_COLOR_PRESETS.slice(0, 6).map((color) => (
<ContextMenuItem
key={color}
onClick={() =>
docsSetTableCellBordersAll(editor, docsDefaultTableBorder(color))
}
>
{color}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSub>
<ContextMenuSubTrigger>Alignement vertical</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem
onClick={() => docsSetTableCellVerticalAlign(editor, "top")}
>
Haut
</ContextMenuItem>
<ContextMenuItem
onClick={() => docsSetTableCellVerticalAlign(editor, "middle")}
>
Milieu
</ContextMenuItem>
<ContextMenuItem
onClick={() => docsSetTableCellVerticalAlign(editor, "bottom")}
>
Bas
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSub>
<ContextMenuSubTrigger>Alignement du tableau</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem onClick={() => docsSetTableAlignment(editor, "left")}>
Gauche
</ContextMenuItem>
<ContextMenuItem onClick={() => docsSetTableAlignment(editor, "center")}>
Centre
</ContextMenuItem>
<ContextMenuItem onClick={() => docsSetTableAlignment(editor, "right")}>
Droite
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSub>
<ContextMenuSubTrigger>Hauteur de ligne</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem onClick={() => docsSetTableRowHeight(editor, "32px")}>
Compacte
</ContextMenuItem>
<ContextMenuItem onClick={() => docsSetTableRowHeight(editor, "48px")}>
Normale
</ContextMenuItem>
<ContextMenuItem onClick={() => docsSetTableRowHeight(editor, null)}>
Automatique
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => editor.chain().focus().toggleHeaderRow().run()}
>
Ligne d&apos;en-tête
</ContextMenuItem>
<ContextMenuItem
onClick={() => editor.chain().focus().toggleHeaderColumn().run()}
>
Colonne d&apos;en-tête
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => editor.chain().focus().deleteRow().run()}
>
Supprimer la ligne
</ContextMenuItem>
<ContextMenuItem
onClick={() => editor.chain().focus().deleteColumn().run()}
>
Supprimer la colonne
</ContextMenuItem>
<ContextMenuItem
className="text-destructive focus:text-destructive"
onClick={() => editor.chain().focus().deleteTable().run()}
>
Supprimer le tableau
</ContextMenuItem>
</>
) : null}
</ContextMenuContent>
</ContextMenu>
)
}

View File

@ -0,0 +1,317 @@
"use client"
import { memo, useCallback, useEffect, useState } from "react"
import { createPortal } from "react-dom"
import type { Editor } from "@tiptap/react"
import { Icon } from "@iconify/react"
import {
ArrowDownToLine,
ArrowUpToLine,
Columns2,
Merge,
PaintBucket,
Rows2,
Split,
Square,
Trash2,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
docsClearTableCellBorders,
docsDefaultTableBorder,
docsSetTableCellBackground,
docsSetTableCellBordersAll,
docsSetTableCellVerticalAlign,
docsTableActive,
docsTableCanMerge,
docsTableCanSplit,
} from "@/lib/drive/docs-table-actions"
import {
DOCS_TABLE_BORDER_COLOR_PRESETS,
DOCS_TABLE_CELL_BACKGROUND_PRESETS,
} from "@/lib/drive/docs-table-types"
import { cn } from "@/lib/utils"
function ToolbarDivider() {
return <span className="mx-0.5 h-6 w-px shrink-0 bg-border" aria-hidden />
}
function IconToolbarButton({
label,
disabled,
onClick,
children,
}: {
label: string
disabled?: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 rounded-full text-popover-foreground hover:bg-accent hover:text-accent-foreground"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
>
{children}
</Button>
)
}
function DocsTableFloatingToolbarInner({
editor,
canvasRef,
disabled,
}: {
editor: Editor | null
canvasRef: React.RefObject<HTMLElement | null>
disabled?: boolean
}) {
const [rect, setRect] = useState<DOMRect | null>(null)
const [, setTick] = useState(0)
const refresh = useCallback(() => {
setTick((value) => value + 1)
if (!editor || !docsTableActive(editor)) {
setRect(null)
return
}
const selectedCell = editor.view.dom.querySelector(
"td.selectedCell, th.selectedCell, .ProseMirror-selectednode table"
) as HTMLElement | null
const table =
selectedCell?.closest("table") ??
(editor.view.dom.querySelector(".ProseMirror-selectednode table") as HTMLElement | null)
if (!table) {
setRect(null)
return
}
setRect(table.getBoundingClientRect())
}, [editor])
useEffect(() => {
if (!editor) return
refresh()
editor.on("selectionUpdate", refresh)
editor.on("transaction", refresh)
const canvas = canvasRef.current
canvas?.addEventListener("scroll", refresh, { passive: true })
window.addEventListener("resize", refresh)
return () => {
editor.off("selectionUpdate", refresh)
editor.off("transaction", refresh)
canvas?.removeEventListener("scroll", refresh)
window.removeEventListener("resize", refresh)
}
}, [canvasRef, editor, refresh])
if (!editor || disabled || !docsTableActive(editor) || !rect) return null
const canMerge = docsTableCanMerge(editor)
const canSplit = docsTableCanSplit(editor)
const toolbar = (
<div
className="docs-table-floating-toolbar pointer-events-auto fixed z-200 -translate-x-1/2"
style={{ left: rect.left + rect.width / 2, top: Math.max(8, rect.top - 44) }}
role="toolbar"
aria-label="Options de tableau"
>
<div className="flex items-center gap-0.5 rounded-full border border-border bg-popover px-1 py-0.5 text-popover-foreground shadow-md">
<IconToolbarButton
label="Insérer une ligne au-dessus"
onClick={() => editor.chain().focus().addRowBefore().run()}
>
<ArrowUpToLine className="size-3.5" />
</IconToolbarButton>
<IconToolbarButton
label="Insérer une ligne en dessous"
onClick={() => editor.chain().focus().addRowAfter().run()}
>
<ArrowDownToLine className="size-3.5" />
</IconToolbarButton>
<ToolbarDivider />
<IconToolbarButton
label="Insérer une colonne à gauche"
onClick={() => editor.chain().focus().addColumnBefore().run()}
>
<Columns2 className="size-3.5 -scale-x-100" />
</IconToolbarButton>
<IconToolbarButton
label="Insérer une colonne à droite"
onClick={() => editor.chain().focus().addColumnAfter().run()}
>
<Columns2 className="size-3.5" />
</IconToolbarButton>
<ToolbarDivider />
<IconToolbarButton
label="Fusionner les cellules"
disabled={!canMerge}
onClick={() => editor.chain().focus().mergeCells().run()}
>
<Merge className="size-3.5" />
</IconToolbarButton>
<IconToolbarButton
label="Scinder la cellule"
disabled={!canSplit}
onClick={() => editor.chain().focus().splitCell().run()}
>
<Split className="size-3.5" />
</IconToolbarButton>
<ToolbarDivider />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 rounded-full"
aria-label="Bordures de cellule"
title="Bordures de cellule"
>
<Square className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-48">
<DropdownMenuItem
onClick={() =>
docsSetTableCellBordersAll(editor, docsDefaultTableBorder("#000000"))
}
>
Bordures noires
</DropdownMenuItem>
<DropdownMenuItem onClick={() => docsClearTableCellBorders(editor)}>
Supprimer les bordures
</DropdownMenuItem>
<DropdownMenuSeparator />
{DOCS_TABLE_BORDER_COLOR_PRESETS.slice(0, 6).map((color) => (
<DropdownMenuItem
key={color}
onClick={() =>
docsSetTableCellBordersAll(editor, docsDefaultTableBorder(color))
}
>
<span
className="mr-2 inline-block size-4 rounded-sm border border-border"
style={{ backgroundColor: color }}
aria-hidden
/>
{color}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 rounded-full"
aria-label="Couleur de cellule"
title="Couleur de cellule"
>
<PaintBucket className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-44">
{DOCS_TABLE_CELL_BACKGROUND_PRESETS.map((preset) => (
<DropdownMenuItem
key={preset.id || "none"}
onClick={() => docsSetTableCellBackground(editor, preset.color || null)}
>
<span
className="mr-2 inline-block size-4 rounded-sm border border-border"
style={{ backgroundColor: preset.color || "transparent" }}
aria-hidden
/>
{preset.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 rounded-full"
aria-label="Alignement vertical"
title="Alignement vertical"
>
<Rows2 className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center">
<DropdownMenuItem onClick={() => docsSetTableCellVerticalAlign(editor, "top")}>
Haut
</DropdownMenuItem>
<DropdownMenuItem onClick={() => docsSetTableCellVerticalAlign(editor, "middle")}>
Milieu
</DropdownMenuItem>
<DropdownMenuItem onClick={() => docsSetTableCellVerticalAlign(editor, "bottom")}>
Bas
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn("size-8 shrink-0 rounded-full")}
aria-label="Plus d'options"
title="Plus d'options"
>
<Icon icon="material-symbols:more-horiz" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-52">
<DropdownMenuItem onClick={() => editor.chain().focus().toggleHeaderRow().run()}>
Ligne d&apos;en-tête
</DropdownMenuItem>
<DropdownMenuItem onClick={() => editor.chain().focus().toggleHeaderColumn().run()}>
Colonne d&apos;en-tête
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => editor.chain().focus().deleteRow().run()}>
Supprimer la ligne
</DropdownMenuItem>
<DropdownMenuItem onClick={() => editor.chain().focus().deleteColumn().run()}>
Supprimer la colonne
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => editor.chain().focus().deleteTable().run()}
>
<Trash2 className="mr-2 size-4" />
Supprimer le tableau
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
return createPortal(toolbar, document.body)
}
export const DocsTableFloatingToolbar = memo(DocsTableFloatingToolbarInner)

View File

@ -12,7 +12,6 @@ import {
ChevronUp,
Bold,
Italic,
Link2,
List,
ListOrdered,
Minus,
@ -50,26 +49,22 @@ import {
stepFontSizePx,
} from "@/lib/drive/docs-font-size"
import {
applyFontFamily,
DOCS_FONT_FAMILIES,
type DocsFontFamilyName,
} from "@/lib/drive/docs-font-family"
import {
DocsGraphicInsertMenu,
DocsGraphicLayoutMenu,
readGraphicToolbarActive,
} from "@/components/drive/richtext/docs-graphic-toolbar-menu"
import { DocsGraphicOptionsPanel } from "@/components/drive/richtext/docs-graphic-options-panel"
import { DocsLinkPopover } from "@/components/drive/richtext/docs-link-popover"
import { DocsParagraphStyleSelect } from "@/components/drive/richtext/docs-paragraph-style-ui"
import { useDocsParagraphStylesContext } from "@/lib/drive/docs-paragraph-styles-context"
import { docsFontStackByName, useDocsFonts } from "@/lib/drive/use-docs-fonts"
import { useDocsToolbarState } from "@/lib/drive/use-docs-toolbar-state"
import { DOCS_LINE_HEIGHT_PRESETS } from "@/lib/drive/docs-line-spacing"
import { readDocsCustomSpacingDraft } from "@/lib/drive/docs-line-spacing-actions"
import { DocsLineSpacingDialog } from "@/components/drive/richtext/docs-line-spacing-dialog"
import { cn } from "@/lib/utils"
const TEXT_STYLES = [
{ id: "paragraph", label: "Texte normal" },
{ id: "heading1", label: "Titre 1" },
{ id: "heading2", label: "Titre 2" },
{ id: "heading3", label: "Titre 3" },
{ id: "heading4", label: "Titre 4" },
] as const
const ZOOM_OPTIONS = [50, 75, 90, 100, 125, 150, 200] as const
const TEXT_COLORS = [
"#000000",
@ -109,17 +104,6 @@ const HIGHLIGHT_COLORS = [
"#e6e6e6",
] as const
const ZOOM_OPTIONS = [50, 75, 90, 100, 125, 150, 200] as const
function applyTextStyle(editor: Editor, styleId: string) {
if (styleId === "paragraph") {
editor.chain().focus().setParagraph().run()
return
}
const level = Number(styleId.replace("heading", "")) as 1 | 2 | 3 | 4
editor.chain().focus().setHeading({ level }).run()
}
function DocsToolbarInner({
editor,
disabled,
@ -130,6 +114,7 @@ function DocsToolbarInner({
showChromeToggle,
chromeCollapsed,
onToggleChromeCollapsed,
onPrint,
embedded,
}: {
editor: Editor | null
@ -141,25 +126,15 @@ function DocsToolbarInner({
showChromeToggle?: boolean
chromeCollapsed?: boolean
onToggleChromeCollapsed?: () => void
onPrint?: () => void
/** Rendered inside DocsEditorWorkspace shell (no outer docs-toolbar-shell). */
embedded?: boolean
}) {
const [linkOpen, setLinkOpen] = useState(false)
const [linkUrl, setLinkUrl] = useState("")
const toolbarState = useDocsToolbarState(editor)
const graphicSelected = readGraphicToolbarActive(editor)
const applyLink = useCallback(() => {
if (!editor) return
const url = linkUrl.trim()
if (!url) {
editor.chain().focus().unsetLink().run()
} else {
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run()
}
setLinkOpen(false)
setLinkUrl("")
}, [editor, linkUrl])
const paragraphStylesCtx = useDocsParagraphStylesContext()
const fontsQuery = useDocsFonts()
const fonts = fontsQuery.data ?? DOCS_FONT_FAMILIES.map((f) => ({ name: f.name, stack: f.stack }))
const [customSpacingOpen, setCustomSpacingOpen] = useState(false)
const segments = useMemo(() => {
if (!editor || !toolbarState) return []
@ -184,6 +159,10 @@ function DocsToolbarInner({
alignJustify,
isBulletList,
isOrderedList,
isTaskList,
canIncreaseIndent,
canDecreaseIndent,
lineHeightPresetId,
} = toolbarState
return [
@ -214,7 +193,7 @@ function DocsToolbarInner({
sepAfter: false,
node: (
<>
<ToolbarIconBtn label="Imprimer" onClick={() => window.print()}>
<ToolbarIconBtn label="Imprimer" onClick={() => onPrint?.() ?? window.print()}>
<Printer className="size-4" />
</ToolbarIconBtn>
<ToolbarIconBtn
@ -260,33 +239,19 @@ function DocsToolbarInner({
{
id: "style",
sepAfter: true,
node: (
<Select
node: paragraphStylesCtx ? (
<DocsParagraphStyleSelect
value={styleId}
onValueChange={(v) => applyTextStyle(editor, v)}
disabled={disabled}
>
documentStyles={paragraphStylesCtx.state.documentStyles}
userStyles={paragraphStylesCtx.state.userStyles}
onValueChange={(value) => paragraphStylesCtx.applyStyle(value)}
/>
) : (
<Select value={styleId} disabled>
<SelectTrigger className="docs-toolbar-select h-7 w-[120px] shrink-0 border-0 bg-transparent px-1 shadow-none">
<SelectValue />
</SelectTrigger>
<SelectContent className="docs-toolbar-select-content docs-toolbar-select-content--style">
{TEXT_STYLES.map((s) => (
<SelectItem
key={s.id}
value={s.id}
className="docs-toolbar-style-item"
>
<span
className={cn(
"docs-toolbar-style-preview",
`docs-toolbar-style-preview--${s.id}`
)}
>
{s.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
),
},
@ -297,16 +262,16 @@ function DocsToolbarInner({
<Select
disabled={disabled}
value={fontFamilyState.kind === "single" ? fontFamilyState.name : undefined}
onValueChange={(value) => applyFontFamily(editor, value as DocsFontFamilyName)}
onValueChange={(value) => {
editor.chain().focus().setFontFamily(docsFontStackByName(fonts, value)).run()
}}
>
<SelectTrigger
className="docs-toolbar-select h-7 w-[108px] shrink-0 border-0 bg-transparent px-1 shadow-none"
style={
fontFamilyState.kind === "single"
? {
fontFamily: DOCS_FONT_FAMILIES.find(
(f) => f.name === fontFamilyState.name
)?.stack,
fontFamily: docsFontStackByName(fonts, fontFamilyState.name),
}
: undefined
}
@ -314,12 +279,9 @@ function DocsToolbarInner({
<SelectValue placeholder="Police" />
</SelectTrigger>
<SelectContent className="docs-toolbar-select-content docs-toolbar-select-content--font">
{DOCS_FONT_FAMILIES.map((f) => (
{fonts.map((f) => (
<SelectItem key={f.name} value={f.name}>
<span
className="docs-toolbar-font-preview"
style={{ fontFamily: f.stack }}
>
<span className="docs-toolbar-font-preview" style={{ fontFamily: f.stack }}>
{f.name}
</span>
</SelectItem>
@ -429,38 +391,7 @@ function DocsToolbarInner({
sepAfter: false,
node: (
<>
<Popover open={linkOpen} onOpenChange={setLinkOpen}>
<PopoverTrigger asChild>
<ToolbarIconBtn
disabled={disabled}
active={isLink}
label="Lien"
onClick={() => {
const prev = editor.getAttributes("link").href as string | undefined
setLinkUrl(prev ?? "")
}}
>
<Link2 className="size-4" />
</ToolbarIconBtn>
</PopoverTrigger>
<PopoverContent className="w-72 p-2" align="start">
<div className="flex gap-2">
<input
type="url"
value={linkUrl}
placeholder="https://"
className="h-8 min-w-0 flex-1 rounded-md border border-input bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") applyLink()
}}
/>
<Button type="button" size="sm" onClick={applyLink}>
OK
</Button>
</div>
</PopoverContent>
</Popover>
<DocsLinkPopover editor={editor} disabled={disabled} active={isLink} />
<ToolbarIconBtn disabled label="Commentaire (bientôt)">
<Icon icon="material-symbols:add-comment-outline" className="size-4" />
</ToolbarIconBtn>
@ -470,17 +401,7 @@ function DocsToolbarInner({
{
id: "insert-graphic",
sepAfter: true,
node: (
<>
<DocsGraphicInsertMenu editor={editor} disabled={disabled} />
{graphicSelected ? (
<>
<DocsGraphicLayoutMenu editor={editor} disabled={disabled} />
<DocsGraphicOptionsPanel editor={editor} disabled={disabled} />
</>
) : null}
</>
),
node: <DocsGraphicInsertMenu editor={editor} disabled={disabled} />,
},
{
id: "align",
@ -532,8 +453,24 @@ function DocsToolbarInner({
<Icon icon="material-symbols:format-line-spacing" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem disabled>Bientôt disponible</DropdownMenuItem>
<DropdownMenuContent align="start" className="min-w-[220px]">
{DOCS_LINE_HEIGHT_PRESETS.map((option) => (
<DropdownMenuItem
key={option.id}
disabled={disabled}
onClick={() => editor.chain().focus().setDocsLineHeight(option.value).run()}
>
<span className="flex w-full items-center justify-between gap-3">
{option.label}
{lineHeightPresetId === option.id ? (
<Icon icon="material-symbols:check" className="size-4 opacity-70" />
) : null}
</span>
</DropdownMenuItem>
))}
<DropdownMenuItem disabled={disabled} onClick={() => setCustomSpacingOpen(true)}>
Espacement personnalisé
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
@ -547,7 +484,10 @@ function DocsToolbarInner({
<ToolbarIconBtn
disabled={disabled}
active={isBulletList}
onClick={() => editor.chain().focus().toggleBulletList().run()}
onClick={() => {
if (isBulletList) editor.chain().focus().toggleBulletList().run()
else editor.chain().focus().applyDocsBulletStyle("disc").run()
}}
label="Liste à puces"
>
<List className="size-4" />
@ -555,18 +495,37 @@ function DocsToolbarInner({
<ToolbarIconBtn
disabled={disabled}
active={isOrderedList}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
onClick={() => {
if (isOrderedList) editor.chain().focus().toggleOrderedList().run()
else editor.chain().focus().applyDocsOrderedStyle("decimal").run()
}}
label="Liste numérotée"
>
<ListOrdered className="size-4" />
</ToolbarIconBtn>
<ToolbarIconBtn disabled label="Liste de contrôle (bientôt)">
<ToolbarIconBtn
disabled={disabled}
active={isTaskList}
onClick={() => {
if (isTaskList) editor.chain().focus().toggleTaskList().run()
else editor.chain().focus().applyDocsChecklistStyle("simple").run()
}}
label="Liste de contrôle"
>
<Icon icon="material-symbols:checklist" className="size-4" />
</ToolbarIconBtn>
<ToolbarIconBtn disabled label="Diminuer le retrait (bientôt)">
<ToolbarIconBtn
disabled={disabled || !canDecreaseIndent}
onClick={() => editor.chain().focus().decreaseDocsIndent().run()}
label="Diminuer le retrait"
>
<Icon icon="material-symbols:format-indent-decrease" className="size-4" />
</ToolbarIconBtn>
<ToolbarIconBtn disabled label="Augmenter le retrait (bientôt)">
<ToolbarIconBtn
disabled={disabled || !canIncreaseIndent}
onClick={() => editor.chain().focus().increaseDocsIndent().run()}
label="Augmenter le retrait"
>
<Icon icon="material-symbols:format-indent-increase" className="size-4" />
</ToolbarIconBtn>
</>
@ -594,10 +553,8 @@ function DocsToolbarInner({
onZoomChange,
spellcheck,
onToggleSpellcheck,
graphicSelected,
linkOpen,
linkUrl,
applyLink,
paragraphStylesCtx,
fonts,
])
const { containerRef, measureRef, visibleCount, hasOverflow } = useToolbarOverflow(
@ -688,7 +645,21 @@ function DocsToolbarInner({
</div>
)
if (embedded) return toolbarRow
if (embedded) {
return (
<>
{toolbarRow}
{editor ? (
<DocsLineSpacingDialog
open={customSpacingOpen}
onOpenChange={setCustomSpacingOpen}
initial={readDocsCustomSpacingDraft(editor)}
onApply={(input) => editor.chain().focus().setDocsCustomSpacing(input).run()}
/>
) : null}
</>
)
}
return (
<div
@ -698,6 +669,14 @@ function DocsToolbarInner({
)}
>
{toolbarRow}
{editor ? (
<DocsLineSpacingDialog
open={customSpacingOpen}
onOpenChange={setCustomSpacingOpen}
initial={readDocsCustomSpacingDraft(editor)}
onApply={(input) => editor.chain().focus().setDocsCustomSpacing(input).run()}
/>
) : null}
</div>
)
}

View File

@ -84,7 +84,7 @@ function DocsVerticalRulerInner({
{ticks.map((tick, index) => (
<div
key={`${tick.pos}-${index}`}
className="pointer-events-none absolute right-0 h-px bg-[#80868b] dark:bg-muted-foreground/70"
className="pointer-events-none absolute right-0 h-px bg-[#bdc1c6] dark:bg-muted-foreground/70"
style={{
top: s(tick.pos),
width: tick.major ? 10 : 5,
@ -97,7 +97,7 @@ function DocsVerticalRulerInner({
.map((tick) => (
<span
key={`vlabel-${tick.pos}`}
className="pointer-events-none absolute right-[11px] -translate-y-1/2 text-[9px] leading-none text-[#5f6368] dark:text-muted-foreground"
className="pointer-events-none absolute right-[11px] -translate-y-1/2 text-[9px] leading-none text-[#9aa0a6] dark:text-muted-foreground"
style={{ top: s(tick.pos) }}
>
{tick.label}

View File

@ -0,0 +1,122 @@
"use client"
import type { ReactNode } from "react"
import Link from "next/link"
import { ArrowLeft, Globe, Lock, Users } from "lucide-react"
import { EditorAccountButton } from "@/components/drive/editor-account-button"
import { OfficeEditorInlineTitle } from "@/components/drive/office-editor-inline-title"
import { ShareDialog } from "@/components/drive/share-dialog"
import { CollabPresenceAvatars } from "@/components/drive/richtext/collab-presence-avatars"
import { Button } from "@/components/ui/button"
import type { DriveShare } from "@/lib/api/types"
import type { CollabPresenceUser } from "@/lib/drive/use-collab-presence"
import {
resolveShareButtonIcon,
type ShareButtonIcon,
} from "@/lib/drive/drive-share-button-state"
import type { UltidrawSaveStatus } from "@/lib/drive/ultidraw-types"
import { cn } from "@/lib/utils"
function ShareButtonIcon({ kind }: { kind: ShareButtonIcon }) {
if (kind === "globe") return <Globe className="h-4 w-4" aria-hidden />
if (kind === "users") return <Users className="h-4 w-4" aria-hidden />
return <Lock className="h-4 w-4" aria-hidden />
}
function saveStatusLabel(status: UltidrawSaveStatus): string {
switch (status) {
case "saving":
return "Enregistrement…"
case "saved":
return "Enregistré dans UltiDrive"
case "error":
return "Erreur d'enregistrement"
default:
return ""
}
}
export function UltidrawChrome({
backHref,
backLabel,
title,
showBack = true,
onRename,
renameDisabled = false,
shares = [],
onShareClick,
showShare = false,
showAccount = false,
saveStatus = "idle",
presenceUsers = [],
trailing,
}: {
backHref?: string
backLabel?: string
title: string
showBack?: boolean
onRename?: (next: string) => Promise<void>
renameDisabled?: boolean
shares?: DriveShare[]
onShareClick?: () => void
showShare?: boolean
showAccount?: boolean
saveStatus?: UltidrawSaveStatus
presenceUsers?: CollabPresenceUser[]
trailing?: ReactNode
}) {
const shareIcon = resolveShareButtonIcon(shares)
const statusLabel = saveStatusLabel(saveStatus)
return (
<>
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3 ultidrive-editor-chrome">
{showBack && backHref ? (
<Button variant="ghost" size="sm" asChild className="shrink-0">
<Link href={backHref}>
<ArrowLeft className="mr-1 h-4 w-4" />
{backLabel ?? "Retour"}
</Link>
</Button>
) : null}
<div className="min-w-0 flex-1">
{onRename ? (
<OfficeEditorInlineTitle
value={title}
onRename={onRename}
disabled={renameDisabled}
/>
) : (
<span className="block truncate px-1.5 text-sm font-medium">{title}</span>
)}
</div>
<div className="flex shrink-0 items-center gap-3">
{presenceUsers.length > 0 ? <CollabPresenceAvatars users={presenceUsers} /> : null}
{statusLabel ? (
<span className="text-xs text-muted-foreground tabular-nums">{statusLabel}</span>
) : null}
{trailing}
{showShare ? (
<Button
type="button"
size="sm"
className={cn(
"gap-2 rounded-full border-0 px-4 shadow-none",
"bg-[#1967d2] text-white hover:bg-[#185abc] hover:text-white",
"dark:bg-[#e8eaed] dark:text-[#3c4043] dark:hover:bg-[#dadce0] dark:hover:text-[#202124]"
)}
onClick={onShareClick}
>
<ShareButtonIcon kind={shareIcon} />
Partager
</Button>
) : null}
{showAccount ? <EditorAccountButton /> : null}
</div>
</div>
{showShare ? <ShareDialog /> : null}
</>
)
}

View File

@ -0,0 +1,312 @@
"use client"
import dynamic from "next/dynamic"
import { useCallback, useEffect, useMemo, useState } from "react"
import { HocuspocusProvider } from "@hocuspocus/provider"
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"
import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types"
import type { AppState, BinaryFiles } from "@excalidraw/excalidraw/types"
import { ExcalidrawBinding, yjsToExcalidraw } from "@mizuka-wu/y-excalidraw"
import * as Y from "yjs"
import { apiClient } from "@/lib/api/client"
import { driveDownloadApiPath } from "@/lib/api/drive-download"
import { UltidrawChrome } from "@/components/drive/ultidraw-chrome"
import { DocsLoadingSplash } from "@/components/drive/richtext/docs-loading-splash"
import {
resolveUltidrawDocumentLoadingPhase,
type DocsLoadingPhase,
} from "@/lib/drive/docs-loading-phase"
import { seedYExcalidrawElements } from "@/lib/drive/ultidraw-seed"
import type { UltidrawSessionResponse, UltidrawSaveStatus } from "@/lib/drive/ultidraw-types"
import { useCollabPresence } from "@/lib/drive/use-collab-presence"
type ParsedDrawFile = {
elements: ExcalidrawElement[]
appState: Partial<AppState>
files: BinaryFiles
}
function parseDrawFile(raw: string): ParsedDrawFile {
const data = JSON.parse(raw) as {
elements?: ExcalidrawElement[]
appState?: Partial<AppState>
files?: BinaryFiles
}
return {
elements: data.elements ?? [],
appState: data.appState ?? {},
files: data.files ?? {},
}
}
function seedYdocIfEmpty(ydoc: Y.Doc, parsed: ParsedDrawFile): void {
const yElements = ydoc.getArray<Y.Map<unknown>>("elements")
if (yElements.length > 0) return
if (parsed.elements.length === 0) return
seedYExcalidrawElements(ydoc, parsed.elements, parsed.files)
}
const ExcalidrawCanvas = dynamic(
async () => {
await import("@excalidraw/excalidraw/index.css")
const { Excalidraw } = await import("@excalidraw/excalidraw")
return Excalidraw
},
{
ssr: false,
loading: () => (
<div className="flex h-full min-h-[360px] items-center justify-center text-sm text-muted-foreground">
Chargement de l&apos;éditeur
</div>
),
}
)
export type UltidrawChromeProps = {
title: string
onRename?: (next: string) => Promise<void>
renameDisabled?: boolean
backHref?: string
backLabel?: string
showBack?: boolean
shares?: import("@/lib/api/types").DriveShare[]
onShareClick?: () => void
showShare?: boolean
showAccount?: boolean
}
export function UltidrawDocumentEditor({
session,
mode,
userName,
userColor,
chrome,
fetchDocument,
deferSplash = false,
onLoadingChange,
}: {
session: UltidrawSessionResponse
mode: "edit" | "view"
userName: string
userColor: string
chrome: UltidrawChromeProps
fetchDocument?: (path: string) => Promise<string>
deferSplash?: boolean
onLoadingChange?: (loading: boolean, phase: DocsLoadingPhase) => void
}) {
const editable = mode === "edit" && session.mode !== "view"
const collaboration = session.collaboration && Boolean(session.wsUrl && session.token)
const [parsed, setParsed] = useState<ParsedDrawFile | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [ydoc, setYdoc] = useState<Y.Doc | null>(null)
const [provider, setProvider] = useState<HocuspocusProvider | null>(null)
const [collabSynced, setCollabSynced] = useState(false)
const [collabError, setCollabError] = useState<string | null>(null)
const [api, setApi] = useState<ExcalidrawImperativeAPI | null>(null)
const [binding, setBinding] = useState<ExcalidrawBinding | null>(null)
const [saveStatus, setSaveStatus] = useState<UltidrawSaveStatus>("idle")
const handleExcalidrawApi = useCallback((next: ExcalidrawImperativeAPI) => {
setApi(next)
}, [])
useEffect(() => {
let cancelled = false
setParsed(null)
setLoadError(null)
void (async () => {
try {
let text: string
if (fetchDocument) {
text = await fetchDocument(session.canonicalPath)
} else if (session.documentUrl) {
const res = await fetch(session.documentUrl)
if (!res.ok) throw new Error("document introuvable")
text = await res.text()
} else {
const blob = await apiClient.getBlob(driveDownloadApiPath(session.canonicalPath))
text = await blob.text()
}
if (!cancelled) setParsed(parseDrawFile(text))
} catch (e) {
if (!cancelled) {
setLoadError(e instanceof Error ? e.message : "Impossible de charger le dessin")
}
}
})()
return () => {
cancelled = true
}
}, [fetchDocument, session.canonicalPath, session.documentUrl])
useEffect(() => {
if (!collaboration) {
setYdoc(null)
return
}
const doc = new Y.Doc()
setYdoc(doc)
return () => {
doc.destroy()
setYdoc(null)
}
}, [collaboration, session.roomId])
useEffect(() => {
if (!collaboration || !ydoc || !parsed) return
setCollabSynced(false)
setCollabError(null)
setApi(null)
setBinding(null)
const p = new HocuspocusProvider({
url: session.wsUrl,
name: session.roomId,
token: session.token,
document: ydoc,
onSynced: () => {
seedYdocIfEmpty(ydoc, parsed)
setCollabSynced(true)
setSaveStatus("saved")
},
onAuthenticationFailed: ({ reason }) => {
setCollabError(reason ?? "Authentification collaboration refusée")
setCollabSynced(false)
},
})
p.awareness?.setLocalStateField("user", {
name: userName,
color: userColor,
})
p.on("status", (event: { status: string }) => {
if (event.status === "connecting") setSaveStatus("saving")
if (event.status === "connected") setSaveStatus("saved")
})
setProvider(p)
return () => {
p.destroy()
setProvider(null)
setCollabSynced(false)
setApi(null)
setBinding(null)
}
}, [collaboration, parsed, session.roomId, session.token, session.wsUrl, userColor, userName, ydoc])
useEffect(() => {
if (!api || !ydoc || !collaboration || !provider || !collabSynced) return
const yElements = ydoc.getArray<Y.Map<unknown>>("elements")
const yAssets = ydoc.getMap("assets")
const b = new ExcalidrawBinding(
yElements,
yAssets,
api,
provider.awareness ?? undefined
)
setBinding(b)
return () => {
b.destroy()
setBinding(null)
}
}, [api, collaboration, collabSynced, provider, ydoc])
const presenceUsers = useCollabPresence(provider, { name: userName, color: userColor })
const excalidrawInitialData = useMemo(() => {
if (!parsed) return undefined
if (collaboration && ydoc && collabSynced) {
const elements = yjsToExcalidraw(ydoc.getArray("elements"))
return {
elements,
appState: parsed.appState,
files: parsed.files,
}
}
if (!collaboration) {
return {
elements: parsed.elements,
appState: parsed.appState,
files: parsed.files,
}
}
return undefined
}, [collaboration, collabSynced, parsed, ydoc])
const collabReady = !collaboration || collabSynced
const editorReady = parsed && collabReady && excalidrawInitialData
const documentLoading = !editorReady
const loadingPhase = resolveUltidrawDocumentLoadingPhase({
collaboration: Boolean(collaboration),
collabSynced,
})
useEffect(() => {
if (!deferSplash) return
onLoadingChange?.(documentLoading, loadingPhase)
}, [deferSplash, documentLoading, loadingPhase, onLoadingChange])
if (loadError) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8">
<p className="text-sm text-muted-foreground">{loadError}</p>
</div>
)
}
if (!deferSplash && documentLoading) {
return (
<DocsLoadingSplash
phase={loadingPhase}
title={chrome.title}
/>
)
}
if (deferSplash && documentLoading) {
return null
}
if (collabError) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8">
<p className="text-sm text-muted-foreground">{collabError}</p>
</div>
)
}
return (
<div className="flex h-full min-h-0 flex-col">
<UltidrawChrome
{...chrome}
saveStatus={collaboration ? saveStatus : "idle"}
presenceUsers={collaboration ? presenceUsers : []}
/>
<div className="relative min-h-0 flex-1">
<ExcalidrawCanvas
key={session.roomId}
excalidrawAPI={handleExcalidrawApi}
initialData={excalidrawInitialData}
langCode="fr-FR"
viewModeEnabled={!editable}
onPointerUpdate={binding?.onPointerUpdate}
UIOptions={{
canvasActions: {
changeViewBackgroundColor: true,
clearCanvas: editable,
export: false,
loadScene: false,
saveToActiveFile: false,
toggleTheme: true,
},
}}
/>
</div>
</div>
)
}

View File

@ -0,0 +1,175 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useQueryClient } from "@tanstack/react-query"
import { Button } from "@/components/ui/button"
import { ArrowLeft } from "lucide-react"
import { UltidrawDocumentEditor } from "@/components/drive/ultidraw-document"
import {
DocsEditorLoadingShell,
useDocsEditorLoadingState,
} from "@/components/drive/richtext/docs-editor-loading-shell"
import { apiClient } from "@/lib/api/client"
import { useDriveFileById, useDriveMutations, useDriveShares } from "@/lib/api/hooks/use-drive-queries"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import { displayFileBaseName } from "@/lib/drive/display-file-name"
import { readDriveEditorReturnTo } from "@/lib/drive/drive-editor-return"
import { resolveRenameName } from "@/lib/drive/drive-default-name"
import { driveFolderHref } from "@/lib/drive/drive-sidebar-tree"
import { resolveDriveEditReturnTo } from "@/lib/drive/drive-url"
import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
import { colorForGuestId } from "@/lib/drive/guest-editor-identity"
import type { UltidrawSessionResponse } from "@/lib/drive/ultidraw-types"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
function fileNameFromPath(filePath: string): string {
return filePath.split("/").filter(Boolean).pop() ?? filePath
}
export function UltidrawEditor({ fileId }: { fileId: string }) {
const queryClient = useQueryClient()
const identity = useChromeIdentity()
const { ready } = useAuthReady()
const setSharePath = useDriveUIStore((s) => s.setSharePath)
const {
data: file,
error: fileError,
isPending: filePending,
isFetching: fileFetching,
} = useDriveFileById(fileId)
const displayPath = file?.path ?? ""
const [session, setSession] = useState<UltidrawSessionResponse | null>(null)
const [sessionError, setSessionError] = useState<string | null>(null)
const fileName = file?.name ?? fileNameFromPath(displayPath)
const title = displayFileBaseName(fileName)
useDriveDocumentTitle(title)
const [backHref, setBackHref] = useState("/drive")
useEffect(() => {
if (!displayPath) return
setBackHref(
resolveDriveEditReturnTo(
null,
displayPath,
(folderPath) => driveFolderHref("files", folderPath),
readDriveEditorReturnTo()
)
)
}, [displayPath])
const { data: sharesData } = useDriveShares(displayPath, Boolean(displayPath))
const { rename } = useDriveMutations()
const refreshFile = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: ["drive", "file", fileId] })
}, [fileId, queryClient])
useEffect(() => {
if (!displayPath) return
let cancelled = false
setSession(null)
setSessionError(null)
void (async () => {
try {
const res = await apiClient.post<UltidrawSessionResponse>("/ultidraw/session", {
path: displayPath,
mode: "edit",
})
if (!cancelled) setSession(res)
} catch (e) {
if (!cancelled) {
setSessionError(e instanceof Error ? e.message : "Impossible d'ouvrir le dessin")
}
}
})()
return () => {
cancelled = true
}
}, [displayPath])
const handleRename = useCallback(
async (input: string) => {
if (!displayPath) return
const newName = resolveRenameName({ name: fileName, type: "file" }, input)
if (displayFileBaseName(fileName) === input.trim()) return
await rename.mutateAsync({ path: displayPath, new_name: newName })
await refreshFile()
},
[displayPath, fileName, refreshFile, rename]
)
const openShare = useCallback(() => {
if (displayPath) setSharePath(displayPath)
}, [displayPath, setSharePath])
const collabUserName = identity?.name?.trim() || identity?.email || "Utilisateur"
const collabUserColor = colorForGuestId(identity?.email ?? collabUserName)
const chrome = useMemo(
() => ({
title,
onRename: handleRename,
renameDisabled: rename.isPending,
backHref,
backLabel: "Drive",
showBack: true,
shares: sharesData?.shares ?? [],
onShareClick: openShare,
showShare: true,
showAccount: true,
}),
[title, handleRename, rename.isPending, backHref, sharesData?.shares, openShare]
)
const resolvingFile = !ready || filePending || fileFetching
const awaitingSession = Boolean(displayPath) && !session && !sessionError
const { documentLoading, documentPhase, onDocumentLoadingChange } = useDocsEditorLoadingState(
session?.roomId ?? displayPath ?? fileId
)
const error =
fileError instanceof Error ? fileError.message : sessionError
const errorView =
error || !file ? (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8">
<p className="text-sm text-muted-foreground">
{error ?? "Dessin introuvable"}
</p>
<Button variant="outline" asChild>
<Link href={backHref}>
<ArrowLeft className="mr-2 h-4 w-4" />
Retour
</Link>
</Button>
</div>
) : null
return (
<DocsEditorLoadingShell
title={title || undefined}
resolvingFile={resolvingFile}
awaitingSession={awaitingSession}
documentLoading={Boolean(session) && documentLoading}
documentPhase={documentPhase}
error={!resolvingFile ? errorView : null}
>
{session && file && !error ? (
<UltidrawDocumentEditor
session={session}
mode="edit"
userName={collabUserName}
userColor={collabUserColor}
chrome={chrome}
deferSplash
onLoadingChange={onDocumentLoadingChange}
/>
) : null}
</DocsEditorLoadingShell>
)
}

View File

@ -22,6 +22,8 @@ export function FirstLaunchSplash({
useEffect(() => {
const root = document.documentElement
const skipForDrive =
pathname === "/" ||
pathname.startsWith("/demo/") ||
isDriveAppPath(pathname) ||
root.dataset.routeScope === "drive" ||
root.dataset.splashSeen === "1"

View File

@ -16,6 +16,7 @@ import { TrashView } from "./trash-view"
import { BulkCreateDialog } from "./bulk-create-dialog"
import { ImportDialog } from "./import-dialog"
import { CONTACTS_SHELL_CLASS } from "@/lib/contacts-chrome-classes"
import { AiChatPanel } from "@/components/ai/ai-chat-panel"
export type ContactsPageView =
| "contacts"
@ -194,6 +195,7 @@ export function ContactsAppShell() {
<ImportDialog open={importOpen} onOpenChange={setImportOpen} />
<BulkCreateDialog open={bulkCreateOpen} onOpenChange={setBulkCreateOpen} onOpenImport={() => setImportOpen(true)} />
<AiChatPanel />
</div>
)
}

View File

@ -1,12 +1,15 @@
"use client"
import { Calendar, Users, CheckSquare, Plus } from "lucide-react"
import { Calendar, Users, CheckSquare, Plus, Sparkles } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useAiPanelStore } from "@/lib/ai/use-ai-panel"
import { cn } from "@/lib/utils"
export function RightPanel() {
const { panelOpen, togglePanel } = useContactsStore()
const aiOpen = useAiPanelStore((s) => s.open)
const openAiPanel = useAiPanelStore((s) => s.openPanel)
return (
<aside className="hidden w-10 shrink-0 flex-col items-center gap-2 bg-transparent py-3 sm:flex">
@ -16,6 +19,18 @@ export function RightPanel() {
<Button variant="ghost" size="icon" className="h-9 w-9 text-gray-600 rounded-full">
<CheckSquare className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
"h-9 w-9 rounded-full",
aiOpen ? "bg-blue-100 text-[#1a73e8]" : "text-gray-600"
)}
onClick={() => openAiPanel({ app: "mail", temporary: true })}
aria-label="UltiAI"
>
<Sparkles className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"

View File

@ -0,0 +1,162 @@
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
export type LandingApp = {
name: string
tagline: string
description: string
icon: string
href?: string
/** Application annoncée, pas encore disponible. */
soon?: boolean
accent: string
}
export const LANDING_APPS: LandingApp[] = [
{
name: "Ultimail",
tagline: "Messagerie",
description:
"Boîte unifiée multi-comptes, libellés intelligents, règles, envoi programmé et tri IA.",
icon: suitePublicAsset("/brand/ultimail-header-icon.png"),
href: "/mail",
accent: "#EA4335",
},
{
name: "UltiDrive",
tagline: "Fichiers & docs",
description:
"Stockage, partage par lien, documents texte, dessins et co-édition en temps réel.",
icon: suitePublicAsset("/ultidrive-mark.svg"),
href: "/drive",
accent: "#4285F4",
},
{
name: "Contacts",
tagline: "Carnet d'adresses",
description:
"Contacts unifiés sur toute la suite, synchronisés avec la messagerie et le partage.",
icon: suitePublicAsset("/contacts-mark.svg"),
href: "/contacts",
accent: "#4285F4",
},
{
name: "UltiAI",
tagline: "Assistant IA",
description:
"Assistant connecté à vos mails et fichiers, fournisseurs OpenAI-compatibles, quotas maîtrisés.",
icon: suitePublicAsset("/ultiai-mark.svg"),
href: "/chat",
accent: "#f2783c",
},
{
name: "Administration",
tagline: "Console d'admin",
description:
"Gestion de l'organisation, SSO, déploiement, quotas IA et réglages centralisés.",
icon: suitePublicAsset("/admin-mark.svg"),
href: "/admin/settings",
accent: "#5a6172",
},
{
name: "Agenda",
tagline: "Calendrier",
description:
"Agenda partagé, invitations et disponibilités — bientôt dans la suite.",
icon: suitePublicAsset("/agenda-mark.svg"),
soon: true,
accent: "#34c77b",
},
{
name: "UltiMeet",
tagline: "Visio",
description:
"Réunions vidéo chiffrées directement depuis votre navigateur — bientôt disponible.",
icon: suitePublicAsset("/ultimeet-mark.svg"),
soon: true,
accent: "#34A853",
},
{
name: "Photos",
tagline: "Galerie",
description:
"Vos photos sauvegardées et organisées, hébergées chez vous — bientôt disponible.",
icon: suitePublicAsset("/photos-mark.svg"),
soon: true,
accent: "#FBBC04",
},
]
export type LandingFeature = {
title: string
description: string
icon: string
/** Carte large dans la grille bento. */
wide?: boolean
}
export const LANDING_FEATURES: LandingFeature[] = [
{
title: "Souveraineté totale",
description:
"Auto-hébergée sur votre infrastructure : vos mails, fichiers et identités restent chez vous. Aucun tracker, aucune télémétrie, aucune dépendance à un cloud étranger.",
icon: "mdi:shield-lock-outline",
wide: true,
},
{
title: "100 % open source",
description:
"Code ouvert et auditable de bout en bout. Pas de boîte noire, pas de verrou propriétaire : vous gardez le contrôle, pour toujours.",
icon: "mdi:source-branch",
},
{
title: "SSO unifié",
description:
"Une seule identité (OIDC / Authentik) pour toute la suite. Connexion unique, sessions sécurisées, déconnexion centralisée.",
icon: "mdi:key-chain-variant",
},
{
title: "IA intégrée, maîtrisée",
description:
"Assistant UltiAI, tri de mails par LLM et agents connectés via MCP. Compatible avec tout fournisseur OpenAI-compatible — y compris vos modèles locaux.",
icon: "mdi:creation-outline",
wide: true,
},
{
title: "Collaboration temps réel",
description:
"Documents, feuilles et dessins co-édités en direct, avec curseurs partagés et historique.",
icon: "mdi:account-multiple-outline",
},
{
title: "Automatisations avancées",
description:
"Règles de tri, webhooks à templates, tokens API à permissions fines pour vos agents et intégrations.",
icon: "mdi:webhook",
},
{
title: "Ergonomie familière",
description:
"Une interface que vos équipes connaissent déjà : migration depuis Google ou Microsoft sans friction ni formation.",
icon: "mdi:gesture-tap-button",
},
]
export type LandingIntegration = {
label: string
icon: string
}
export const LANDING_INTEGRATIONS: LandingIntegration[] = [
{ label: "IMAP / SMTP", icon: "mdi:email-sync-outline" },
{ label: "OIDC / Authentik", icon: "mdi:shield-key-outline" },
{ label: "OnlyOffice", icon: "mdi:file-document-edit-outline" },
{ label: "Excalidraw", icon: "mdi:draw" },
{ label: "Yjs temps réel", icon: "mdi:sync" },
{ label: "Mistral", icon: "simple-icons:mistralai" },
{ label: "OpenAI-compatible", icon: "mdi:robot-outline" },
{ label: "MCP Agents", icon: "mdi:connection" },
{ label: "Webhooks", icon: "mdi:webhook" },
{ label: "PostgreSQL", icon: "simple-icons:postgresql" },
{ label: "Docker", icon: "simple-icons:docker" },
{ label: "Nextcloud", icon: "simple-icons:nextcloud" },
]

View File

@ -0,0 +1,175 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { Icon } from "@iconify/react"
import { LandingReveal } from "@/components/landing/landing-reveal"
import { cn } from "@/lib/utils"
type DemoTab = {
id: string
label: string
icon: string
src: string
fakeUrl: string
hint: string
}
const DEMO_TABS: DemoTab[] = [
{
id: "mail",
label: "Boîte mail",
icon: "mdi:email-outline",
src: "/demo/mail",
fakeUrl: "suite.votre-domaine.fr/mail",
hint: "Lisez, archivez, répondez, composez — comme dans la vraie boîte.",
},
{
id: "docs",
label: "Éditeur UltiDocs",
icon: "mdi:file-document-edit-outline",
src: "/demo/docs",
fakeUrl: "suite.votre-domaine.fr/docs",
hint: "Le vrai éditeur de la suite : mise en forme, tableaux, styles, pages.",
},
]
export function LandingDemoSection() {
const sectionRef = useRef<HTMLElement>(null)
const [visible, setVisible] = useState(false)
const [activeTab, setActiveTab] = useState(DEMO_TABS[0].id)
/** Onglets dont l'iframe a été montée (état conservé au changement d'onglet). */
const [mounted, setMounted] = useState<Record<string, boolean>>({})
/** Incrément par onglet pour réinitialiser la démo (remount iframe). */
const [resetKeys, setResetKeys] = useState<Record<string, number>>({})
useEffect(() => {
const node = sectionRef.current
if (!node || visible) return
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setVisible(true)
observer.disconnect()
}
},
{ rootMargin: "240px 0px" }
)
observer.observe(node)
return () => observer.disconnect()
}, [visible])
useEffect(() => {
if (!visible) return
setMounted((prev) => (prev[activeTab] ? prev : { ...prev, [activeTab]: true }))
}, [visible, activeTab])
const active = DEMO_TABS.find((tab) => tab.id === activeTab) ?? DEMO_TABS[0]
return (
<section id="demo" ref={sectionRef} className="scroll-mt-20 px-4 py-20 sm:px-6">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-10">
<LandingReveal className="mx-auto flex max-w-2xl flex-col items-center gap-4 text-center">
<span className="rounded-full bg-[var(--landing-chip)] px-3.5 py-1 text-xs font-semibold uppercase tracking-wider text-[var(--landing-chip-fg)]">
Démo interactive
</span>
<h2 className="text-balance text-3xl font-bold tracking-tight sm:text-4xl">
Essayez <span className="landing-gradient-text">maintenant</span>,
sans compte
</h2>
<p className="text-balance text-base leading-relaxed text-[var(--landing-muted)]">
De vraies fenêtres, de vrais comportements. Tout reste dans votre
onglet : <strong className="font-semibold text-[var(--landing-fg)]">zéro rétention</strong>,
rien n'est envoyé ni enregistré.
</p>
</LandingReveal>
<LandingReveal delay={0.1} className="flex flex-col gap-4">
{/* Onglets */}
<div className="flex flex-wrap items-center justify-center gap-2">
{DEMO_TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
"landing-cta h-10 px-5 text-sm",
tab.id === activeTab
? "landing-cta--primary"
: "landing-cta--ghost"
)}
aria-pressed={tab.id === activeTab}
>
<Icon icon={tab.icon} className="size-4.5" aria-hidden />
{tab.label}
</button>
))}
</div>
{/* Fenêtre virtuelle */}
<div className="landing-glass-strong overflow-hidden rounded-2xl shadow-[0_40px_90px_-40px_rgba(30,40,90,0.5)]">
<div className="flex items-center gap-1.5 px-3 py-2.5">
<span className="size-2.5 rounded-full bg-[#ff5f57]" aria-hidden />
<span className="size-2.5 rounded-full bg-[#febc2e]" aria-hidden />
<span className="size-2.5 rounded-full bg-[#28c840]" aria-hidden />
<div className="ml-3 flex h-7 min-w-0 flex-1 items-center gap-2 rounded-full bg-[var(--landing-chip)] px-3 text-xs text-[var(--landing-muted)]">
<Icon icon="mdi:lock-outline" className="size-3.5 shrink-0" aria-hidden />
<span className="truncate">{active.fakeUrl}</span>
</div>
<button
type="button"
onClick={() =>
setResetKeys((prev) => ({
...prev,
[active.id]: (prev[active.id] ?? 0) + 1,
}))
}
className="ml-2 flex h-7 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium text-[var(--landing-muted)] transition-colors hover:bg-[var(--landing-chip)] hover:text-[var(--landing-fg)]"
title="Réinitialiser la démo (tout est éphémère)"
>
<Icon icon="mdi:restore" className="size-4" aria-hidden />
<span className="hidden sm:inline">Réinitialiser</span>
</button>
<a
href={active.src}
target="_blank"
rel="noopener noreferrer"
className="flex h-7 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium text-[var(--landing-muted)] transition-colors hover:bg-[var(--landing-chip)] hover:text-[var(--landing-fg)]"
title="Ouvrir la démo en plein écran"
>
<Icon icon="mdi:open-in-new" className="size-4" aria-hidden />
<span className="hidden sm:inline">Plein écran</span>
</a>
</div>
<div className="relative h-[30rem] border-t border-[var(--landing-line)] bg-[var(--landing-bg)] sm:h-[34rem] lg:h-[38rem]">
{DEMO_TABS.map((tab) =>
mounted[tab.id] ? (
<iframe
key={`${tab.id}-${resetKeys[tab.id] ?? 0}`}
src={tab.src}
title={`Démo ${tab.label}`}
loading="lazy"
className={cn(
"absolute inset-0 h-full w-full",
tab.id === activeTab ? "block" : "hidden"
)}
/>
) : null
)}
{!mounted[active.id] ? (
<div className="absolute inset-0 flex items-center justify-center text-sm text-[var(--landing-muted)]">
Chargement de la démo
</div>
) : null}
</div>
</div>
<p className="flex flex-wrap items-center justify-center gap-2 text-center text-sm text-[var(--landing-muted)]">
<Icon icon="mdi:incognito" className="size-4.5 shrink-0" aria-hidden />
{active.hint} Rechargez la page : tout disparaît.
</p>
</LandingReveal>
</div>
</section>
)
}

View File

@ -0,0 +1,120 @@
"use client"
import { useRef, useState } from "react"
import Link from "next/link"
import { Icon } from "@iconify/react"
import { AccountAvatar } from "@/components/suite/account-avatar"
import { AccountSwitcherDropdown } from "@/components/suite/account-switcher-dropdown"
import { SuiteFavoritesMenu } from "@/components/suite/suite-favorites-menu"
import { Button } from "@/components/ui/button"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config"
import { cn } from "@/lib/utils"
const NAV_LINKS = [
{ label: "Démo", href: "#demo" },
{ label: "Applications", href: "#applications" },
{ label: "Fonctionnalités", href: "#fonctionnalites" },
{ label: "Souveraineté", href: "#souverainete" },
]
export function LandingHeader({ scrolled }: { scrolled: boolean }) {
const identity = useChromeIdentity()
const [accountMenuOpen, setAccountMenuOpen] = useState(false)
const accountMenuRef = useRef<HTMLDivElement>(null)
return (
<header
className={cn(
"sticky top-0 z-40 transition-[background-color,border-color,box-shadow,backdrop-filter] duration-300",
scrolled
? "landing-glass-strong shadow-[0_8px_30px_-18px_rgba(0,0,0,0.35)]"
: "border-b border-transparent"
)}
>
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between gap-4 px-4 sm:px-6">
<Link
href="/"
className="flex min-w-0 items-center gap-2.5 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Ulti Suite — Accueil"
>
<img
src="/ultisuite-mark.svg"
alt=""
width={48}
height={48}
draggable={false}
className="h-8 w-8 shrink-0 object-contain select-none"
aria-hidden
/>
<span className="truncate text-lg font-semibold tracking-tight">
Ulti<span className="landing-gradient-text">Suite</span>
</span>
</Link>
<nav className="hidden items-center gap-1 md:flex" aria-label="Sections">
{NAV_LINKS.map((link) => (
<a
key={link.href}
href={link.href}
className="rounded-full px-3.5 py-1.5 text-sm font-medium text-[var(--landing-muted)] transition-colors hover:bg-[var(--landing-chip)] hover:text-[var(--landing-fg)]"
>
{link.label}
</a>
))}
</nav>
<div className="flex shrink-0 items-center gap-2">
{identity ? (
<>
<Link
href="/mail/inbox"
className="landing-cta landing-cta--primary hidden h-9 px-4 text-sm sm:inline-flex"
>
Ouvrir Ultimail
<Icon icon="mdi:arrow-right" className="size-4" aria-hidden />
</Link>
<SuiteFavoritesMenu onOpen={() => setAccountMenuOpen(false)} />
<div className="relative" ref={accountMenuRef}>
<Button
variant="ghost"
size="icon-lg"
className="size-10 overflow-hidden rounded-full p-0"
aria-label={`Compte : ${identity.email}`}
aria-expanded={accountMenuOpen}
aria-haspopup="dialog"
onClick={() => setAccountMenuOpen((open) => !open)}
>
<AccountAvatar
account={{ name: identity.name, email: identity.email }}
size="md"
/>
</Button>
<AccountSwitcherDropdown
open={accountMenuOpen}
onOpenChange={setAccountMenuOpen}
containerRef={accountMenuRef}
/>
</div>
</>
) : (
<>
<a
href={getAuthentikEnrollmentUrl()}
className="landing-cta landing-cta--ghost hidden h-9 px-4 text-sm sm:inline-flex"
>
Créer un compte
</a>
<Link
href="/login"
className="landing-cta landing-cta--primary h-9 px-4 text-sm"
>
Se connecter
</Link>
</>
)}
</div>
</div>
</header>
)
}

View File

@ -0,0 +1,210 @@
"use client"
import Link from "next/link"
import { Icon } from "@iconify/react"
import { LandingReveal } from "@/components/landing/landing-reveal"
import { LANDING_APPS } from "@/components/landing/landing-data"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config"
import { cn } from "@/lib/utils"
function HeroDock() {
const apps = LANDING_APPS.filter((app) => app.href)
return (
<div className="flex flex-wrap items-center justify-center gap-3 sm:gap-4">
{apps.map((app, index) => (
<Link
key={app.name}
href={app.href!}
title={app.name}
className="landing-dock-tile landing-glass group flex size-14 items-center justify-center rounded-2xl transition-transform hover:scale-110 sm:size-16"
style={{ "--float-delay": `${index * 0.55}s` } as React.CSSProperties}
>
<img
src={app.icon}
alt={app.name}
className="size-8 object-contain transition-transform group-hover:scale-110 sm:size-9"
draggable={false}
/>
</Link>
))}
</div>
)
}
/** Fenêtre « produit » stylisée (pas un vrai screenshot — du pur CSS). */
function HeroPreview() {
const rows = [
{ from: "Conseil d'administration", subject: "Ordre du jour — revue Q3", time: "09:12", unread: true },
{ from: "UltiAI", subject: "Résumé de vos 12 mails non lus", time: "08:47", ai: true },
{ from: "Marie Laurent", subject: "Spécifications produit v2 (UltiDocs)", time: "08:30" },
{ from: "Infra", subject: "Sauvegarde hebdomadaire effectuée ✓", time: "07:58" },
]
return (
<div
className="landing-glass-strong relative mx-auto w-full max-w-3xl rounded-2xl p-2 shadow-[0_40px_90px_-40px_rgba(30,40,90,0.45)]"
aria-hidden
>
<div className="flex items-center gap-1.5 px-3 py-2">
<span className="size-2.5 rounded-full bg-[#ff5f57]" />
<span className="size-2.5 rounded-full bg-[#febc2e]" />
<span className="size-2.5 rounded-full bg-[#28c840]" />
<div className="ml-3 flex h-6 flex-1 items-center rounded-full bg-[var(--landing-chip)] px-3 text-[11px] text-[var(--landing-muted)]">
suite.votre-domaine.fr/mail
</div>
</div>
<div className="flex overflow-hidden rounded-xl border border-[var(--landing-line)]">
<div className="hidden w-40 shrink-0 flex-col gap-1 border-r border-[var(--landing-line)] bg-[var(--landing-card)] p-3 sm:flex">
<div className="landing-cta--primary landing-cta mb-2 h-8 w-full rounded-full text-xs">
Nouveau message
</div>
{["Boîte de réception", "Favoris", "Programmés", "Brouillons"].map(
(label, i) => (
<div
key={label}
className={cn(
"flex items-center justify-between rounded-full px-3 py-1.5 text-xs",
i === 0
? "bg-[var(--landing-chip)] font-semibold text-[var(--landing-chip-fg)]"
: "text-[var(--landing-muted)]"
)}
>
<span>{label}</span>
{i === 0 ? <span>12</span> : null}
</div>
)
)}
</div>
<div className="flex-1 divide-y divide-[var(--landing-line)] bg-[var(--landing-card-strong)]">
{rows.map((row) => (
<div key={row.subject} className="flex items-center gap-3 px-4 py-3">
<span
className={cn(
"size-2 shrink-0 rounded-full",
row.unread ? "bg-[var(--landing-glow-a)]" : "bg-transparent"
)}
/>
<span
className={cn(
"w-32 shrink-0 truncate text-xs sm:w-40 sm:text-[13px]",
row.unread ? "font-semibold" : "text-[var(--landing-muted)]"
)}
>
{row.from}
</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--landing-muted)] sm:text-[13px]">
{row.ai ? (
<span className="mr-1.5 inline-flex items-center gap-1 rounded-full bg-[var(--landing-chip)] px-1.5 py-px text-[10px] font-semibold text-[var(--landing-chip-fg)]">
<Icon icon="mdi:creation-outline" className="size-3" />
IA
</span>
) : null}
{row.subject}
</span>
<span className="shrink-0 text-[11px] text-[var(--landing-muted)]">
{row.time}
</span>
</div>
))}
</div>
</div>
</div>
)
}
export function LandingHero() {
const identity = useChromeIdentity()
return (
<section className="relative px-4 pb-20 pt-14 sm:px-6 sm:pt-20">
<div className="mx-auto flex w-full max-w-5xl flex-col items-center gap-8 text-center">
<LandingReveal>
<span className="landing-glass inline-flex items-center gap-2.5 rounded-full px-4 py-1.5 text-xs font-medium text-[var(--landing-muted)] sm:text-sm">
<span className="landing-pulse-dot" aria-hidden />
Open source · Souveraine · Prête pour l'IA
</span>
</LandingReveal>
<LandingReveal delay={0.08}>
<h1 className="text-balance text-4xl font-bold leading-[1.06] tracking-tight sm:text-6xl lg:text-7xl">
Toute votre suite de travail.
<br />
<span className="landing-gradient-text">Chez vous.</span>
</h1>
</LandingReveal>
<LandingReveal delay={0.16}>
<p className="mx-auto max-w-2xl text-balance text-base leading-relaxed text-[var(--landing-muted)] sm:text-lg">
Mails, fichiers, documents collaboratifs, contacts et assistant IA :
l'alternative complète à Google Workspace et Microsoft 365,
open source et hébergée sur <em className="not-italic font-semibold text-[var(--landing-fg)]">votre</em> infrastructure.
</p>
</LandingReveal>
<LandingReveal delay={0.24} className="flex flex-col items-center gap-4">
{identity ? (
<>
<p className="text-sm text-[var(--landing-muted)]">
Bonjour {identity.firstName} votre suite vous attend.
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<Link
href="/mail/inbox"
className="landing-cta landing-cta--primary h-12 px-7 text-base"
>
Ouvrir Ultimail
<Icon icon="mdi:arrow-right" className="size-5" aria-hidden />
</Link>
<Link
href="/drive"
className="landing-cta landing-cta--ghost h-12 px-7 text-base"
>
Ouvrir UltiDrive
</Link>
</div>
</>
) : (
<>
<div className="flex flex-wrap items-center justify-center gap-3">
<Link
href="/login"
className="landing-cta landing-cta--primary h-12 px-7 text-base"
>
Se connecter
<Icon icon="mdi:arrow-right" className="size-5" aria-hidden />
</Link>
<a
href={getAuthentikEnrollmentUrl()}
className="landing-cta landing-cta--ghost h-12 px-7 text-base"
>
Créer un compte
</a>
</div>
<a
href="#demo"
className="group inline-flex items-center gap-1.5 text-sm font-medium text-[var(--landing-muted)] transition-colors hover:text-[var(--landing-fg)]"
>
ou essayez la démo interactive, sans compte
<Icon
icon="mdi:arrow-down"
className="size-4 transition-transform group-hover:translate-y-0.5"
aria-hidden
/>
</a>
</>
)}
</LandingReveal>
<LandingReveal delay={0.32} className="w-full">
<HeroDock />
</LandingReveal>
<LandingReveal delay={0.4} className="w-full pt-6">
<HeroPreview />
</LandingReveal>
</div>
</section>
)
}

View File

@ -0,0 +1,48 @@
"use client"
import { useRef, useState } from "react"
import { LandingDemoSection } from "@/components/landing/landing-demo"
import { LandingHeader } from "@/components/landing/landing-header"
import { LandingHero } from "@/components/landing/landing-hero"
import {
LandingAppsSection,
LandingFeaturesSection,
LandingFooter,
LandingIntegrationsSection,
LandingSovereigntySection,
} from "@/components/landing/landing-sections"
export function LandingPage() {
const scrollRef = useRef<HTMLDivElement>(null)
const [scrolled, setScrolled] = useState(false)
return (
<div
ref={scrollRef}
className="landing-root relative h-dvh overflow-y-auto overflow-x-hidden"
onScroll={() => {
const top = scrollRef.current?.scrollTop ?? 0
setScrolled((prev) => (top > 8 ? true : top <= 2 ? false : prev))
}}
>
<div className="landing-backdrop" aria-hidden>
<div className="landing-orb landing-orb--a" />
<div className="landing-orb landing-orb--b" />
<div className="landing-orb landing-orb--c" />
</div>
<div className="relative z-10 flex min-h-full flex-col">
<LandingHeader scrolled={scrolled} />
<main className="flex-1">
<LandingHero />
<LandingIntegrationsSection />
<LandingDemoSection />
<LandingAppsSection />
<LandingFeaturesSection />
<LandingSovereigntySection />
</main>
<LandingFooter />
</div>
</div>
)
}

View File

@ -0,0 +1,54 @@
"use client"
import { useEffect, useRef, useState, type ReactNode } from "react"
import { cn } from "@/lib/utils"
interface LandingRevealProps {
children: ReactNode
className?: string
/** Délai (s) appliqué à la transition — pour le stagger. */
delay?: number
as?: "div" | "section" | "li" | "span"
}
/** Révèle son contenu à l'entrée dans le viewport (une seule fois). */
export function LandingReveal({
children,
className,
delay = 0,
as: Tag = "div",
}: LandingRevealProps) {
const ref = useRef<HTMLElement | null>(null)
const [revealed, setRevealed] = useState(false)
useEffect(() => {
const node = ref.current
if (!node || revealed) return
if (typeof IntersectionObserver === "undefined") {
setRevealed(true)
return
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setRevealed(true)
observer.disconnect()
}
},
{ threshold: 0.12, rootMargin: "0px 0px -36px 0px" }
)
observer.observe(node)
return () => observer.disconnect()
}, [revealed])
return (
<Tag
ref={ref as never}
className={cn("landing-reveal", className)}
data-revealed={revealed}
style={delay ? ({ "--reveal-delay": `${delay}s` } as React.CSSProperties) : undefined}
>
{children}
</Tag>
)
}

View File

@ -0,0 +1,358 @@
"use client"
import Link from "next/link"
import { Icon } from "@iconify/react"
import { LandingReveal } from "@/components/landing/landing-reveal"
import {
LANDING_APPS,
LANDING_FEATURES,
LANDING_INTEGRATIONS,
} from "@/components/landing/landing-data"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config"
import { cn } from "@/lib/utils"
function SectionHeading({
eyebrow,
title,
description,
}: {
eyebrow: string
title: React.ReactNode
description?: string
}) {
return (
<LandingReveal className="mx-auto flex max-w-2xl flex-col items-center gap-4 text-center">
<span className="rounded-full bg-[var(--landing-chip)] px-3.5 py-1 text-xs font-semibold uppercase tracking-wider text-[var(--landing-chip-fg)]">
{eyebrow}
</span>
<h2 className="text-balance text-3xl font-bold tracking-tight sm:text-4xl">
{title}
</h2>
{description ? (
<p className="text-balance text-base leading-relaxed text-[var(--landing-muted)]">
{description}
</p>
) : null}
</LandingReveal>
)
}
/* ---------- Applications ---------- */
export function LandingAppsSection() {
return (
<section id="applications" className="scroll-mt-20 px-4 py-20 sm:px-6">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-12">
<SectionHeading
eyebrow="Applications"
title={
<>
Une suite <span className="landing-gradient-text">connectée</span>,
pas une collection d'outils
</>
}
description="Chaque application partage la même identité, les mêmes contacts et le même stockage. Ouvrez, l'écosystème suit."
/>
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{LANDING_APPS.map((app, index) => {
const card = (
<div
className={cn(
"landing-glass landing-halo-card flex h-full flex-col gap-3 rounded-2xl p-5",
app.soon && "opacity-75"
)}
>
<div className="flex items-center justify-between">
<span
className="flex size-11 items-center justify-center rounded-xl"
style={{ backgroundColor: `${app.accent}1a` }}
>
<img
src={app.icon}
alt=""
className="size-7 object-contain"
draggable={false}
aria-hidden
/>
</span>
{app.soon ? (
<span className="rounded-full border border-[var(--landing-line)] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--landing-muted)]">
Bientôt
</span>
) : (
<Icon
icon="mdi:arrow-top-right"
className="size-4 text-[var(--landing-muted)] transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5"
aria-hidden
/>
)}
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--landing-muted)]">
{app.tagline}
</p>
<h3 className="text-lg font-semibold tracking-tight">
{app.name}
</h3>
</div>
<p className="text-sm leading-relaxed text-[var(--landing-muted)]">
{app.description}
</p>
</div>
)
return (
<LandingReveal as="li" key={app.name} delay={(index % 4) * 0.07}>
{app.href && !app.soon ? (
<Link href={app.href} className="group block h-full rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ring/50">
{card}
</Link>
) : (
card
)}
</LandingReveal>
)
})}
</ul>
</div>
</section>
)
}
/* ---------- Fonctionnalités (bento) ---------- */
export function LandingFeaturesSection() {
return (
<section id="fonctionnalites" className="scroll-mt-20 px-4 py-20 sm:px-6">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-12">
<SectionHeading
eyebrow="Fonctionnalités"
title={
<>
Conçue pour remplacer,
<br className="hidden sm:block" /> pensée pour{" "}
<span className="landing-gradient-text">durer</span>
</>
}
description="Tout ce que vous attendez d'une suite moderne — sans céder vos données pour l'obtenir."
/>
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{LANDING_FEATURES.map((feature, index) => (
<LandingReveal
as="li"
key={feature.title}
delay={(index % 4) * 0.07}
className={cn(feature.wide && "sm:col-span-2")}
>
<div className="landing-glass landing-halo-card flex h-full flex-col gap-3 rounded-2xl p-6">
<span className="flex size-11 items-center justify-center rounded-xl bg-[var(--landing-chip)] text-[var(--landing-chip-fg)]">
<Icon icon={feature.icon} className="size-6" aria-hidden />
</span>
<h3 className="text-lg font-semibold tracking-tight">
{feature.title}
</h3>
<p className="text-sm leading-relaxed text-[var(--landing-muted)]">
{feature.description}
</p>
</div>
</LandingReveal>
))}
</ul>
</div>
</section>
)
}
/* ---------- Souveraineté + stats ---------- */
const STATS = [
{ value: "100 %", label: "open source, auditable" },
{ value: "0", label: "tracker, télémétrie ou pub" },
{ value: "1", label: "identité SSO pour toute la suite" },
{ value: "∞", label: "contrôle : c'est votre serveur" },
]
export function LandingSovereigntySection() {
return (
<section id="souverainete" className="scroll-mt-20 px-4 py-20 sm:px-6">
<div className="mx-auto w-full max-w-6xl">
<LandingReveal>
<div className="landing-glass-strong relative overflow-hidden rounded-3xl px-6 py-12 sm:px-12 sm:py-16">
<div
className="pointer-events-none absolute inset-0 opacity-60"
style={{
background:
"radial-gradient(60% 90% at 15% 0%, color-mix(in oklab, var(--landing-glow-a) 22%, transparent), transparent 70%), radial-gradient(50% 80% at 90% 100%, color-mix(in oklab, var(--landing-glow-b) 18%, transparent), transparent 70%)",
}}
aria-hidden
/>
<div className="relative flex flex-col gap-10">
<div className="flex max-w-2xl flex-col gap-4">
<span className="inline-flex w-fit items-center gap-2 rounded-full bg-[var(--landing-chip)] px-3.5 py-1 text-xs font-semibold uppercase tracking-wider text-[var(--landing-chip-fg)]">
<Icon icon="mdi:shield-check-outline" className="size-4" aria-hidden />
Souveraineté
</span>
<h2 className="text-balance text-3xl font-bold tracking-tight sm:text-4xl">
Vos données ne quittent jamais{" "}
<span className="landing-gradient-text">votre territoire</span>
</h2>
<p className="text-base leading-relaxed text-[var(--landing-muted)]">
Déployée chez vous ou chez l'hébergeur de votre choix, la
suite Ulti garde mails, fichiers et identités sous votre
juridiction. Migration progressive : rattachez vos comptes
existants et avancez à votre rythme.
</p>
</div>
<dl className="grid grid-cols-2 gap-6 lg:grid-cols-4">
{STATS.map((stat, index) => (
<LandingReveal key={stat.label} delay={index * 0.08}>
<div className="flex flex-col gap-1 border-l-2 border-[var(--landing-glow-a)] pl-4">
<dt className="order-2 text-sm text-[var(--landing-muted)]">
{stat.label}
</dt>
<dd className="order-1 text-3xl font-bold tracking-tight sm:text-4xl">
{stat.value}
</dd>
</div>
</LandingReveal>
))}
</dl>
</div>
</div>
</LandingReveal>
</div>
</section>
)
}
/* ---------- Intégrations (marquee) ---------- */
function IntegrationsTrack() {
return (
<div className="landing-marquee__track" aria-hidden>
{LANDING_INTEGRATIONS.map((item) => (
<span
key={item.label}
className="flex shrink-0 items-center gap-2 text-sm font-medium text-[var(--landing-muted)]"
>
<Icon icon={item.icon} className="size-5" aria-hidden />
{item.label}
</span>
))}
</div>
)
}
export function LandingIntegrationsSection() {
return (
<section className="px-0 py-10">
<LandingReveal className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<p className="text-center text-xs font-semibold uppercase tracking-widest text-[var(--landing-muted)]">
S'intègre avec vos standards ouverts
</p>
<div
className="landing-marquee"
role="list"
aria-label={LANDING_INTEGRATIONS.map((i) => i.label).join(", ")}
>
<IntegrationsTrack />
<IntegrationsTrack />
</div>
</LandingReveal>
</section>
)
}
/* ---------- CTA final + footer ---------- */
export function LandingFooter() {
const identity = useChromeIdentity()
return (
<footer className="px-4 pb-10 pt-20 sm:px-6">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-14">
<LandingReveal className="flex flex-col items-center gap-6 text-center">
<h2 className="text-balance text-3xl font-bold tracking-tight sm:text-5xl">
Prêt à reprendre{" "}
<span className="landing-gradient-text">le contrôle</span> ?
</h2>
<p className="max-w-xl text-balance text-base text-[var(--landing-muted)]">
{identity
? "Votre suite est déjà prête. Ouvrez une application et continuez là où vous en étiez."
: "Créez votre compte et découvrez une suite complète qui travaille pour vous — pas l'inverse."}
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
{identity ? (
<>
<Link
href="/mail/inbox"
className="landing-cta landing-cta--primary h-12 px-7 text-base"
>
Ouvrir Ultimail
<Icon icon="mdi:arrow-right" className="size-5" aria-hidden />
</Link>
<Link
href="/chat"
className="landing-cta landing-cta--ghost h-12 px-7 text-base"
>
Parler à UltiAI
</Link>
</>
) : (
<>
<a
href={getAuthentikEnrollmentUrl()}
className="landing-cta landing-cta--primary h-12 px-7 text-base"
>
Créer un compte
<Icon icon="mdi:arrow-right" className="size-5" aria-hidden />
</a>
<Link
href="/login"
className="landing-cta landing-cta--ghost h-12 px-7 text-base"
>
Se connecter
</Link>
</>
)}
</div>
</LandingReveal>
<div className="flex flex-col items-center justify-between gap-4 border-t border-[var(--landing-line)] pt-8 text-sm text-[var(--landing-muted)] sm:flex-row">
<div className="flex items-center gap-2.5">
<img
src="/ultisuite-mark.svg"
alt=""
className="h-6 w-6 object-contain"
draggable={false}
aria-hidden
/>
<span className="font-semibold text-[var(--landing-fg)]">
UltiSuite
</span>
<span aria-hidden>·</span>
<span>Suite collaborative souveraine et open source</span>
</div>
<nav className="flex items-center gap-4" aria-label="Liens">
<Link href="/mail" className="transition-colors hover:text-[var(--landing-fg)]">
Ultimail
</Link>
<Link href="/drive" className="transition-colors hover:text-[var(--landing-fg)]">
UltiDrive
</Link>
<Link href="/contacts" className="transition-colors hover:text-[var(--landing-fg)]">
Contacts
</Link>
<Link href="/chat" className="transition-colors hover:text-[var(--landing-fg)]">
UltiAI
</Link>
</nav>
</div>
</div>
</footer>
)
}

View File

@ -134,6 +134,16 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
export_mirror_format: policy.richtext?.export_mirror_format ?? "",
hocuspocus_url: policy.richtext?.hocuspocus_url ?? "",
},
aiAssistant: {
enabled: policy.ai_assistant?.enabled ?? false,
openwebui_internal_url: policy.ai_assistant?.openwebui_internal_url ?? "",
public_path: policy.ai_assistant?.public_path ?? "/ai",
embed_default_temporary: policy.ai_assistant?.embed_default_temporary ?? true,
default_model: policy.ai_assistant?.default_model ?? "",
enabled_tools: policy.ai_assistant?.enabled_tools ?? ["mail", "drive", "contacts", "search"],
chat_sync_enabled: policy.ai_assistant?.chat_sync_enabled ?? true,
chat_nc_path: policy.ai_assistant?.chat_nc_path ?? "/.ultimail/ai/chats",
},
plugins: policy.plugins ?? [],
integrations: mergeIntegrations(policy.integrations as IntegrationEntry[]),
}
@ -187,6 +197,7 @@ export function storeToApiOrgPolicy(state: OrgSettingsState): ApiOrgPolicy {
mailing: { ...state.mailing },
onlyoffice: { ...state.onlyoffice },
richtext: { ...state.richtext },
ai_assistant: { ...state.aiAssistant },
plugins: state.plugins.map(({ id, name, description, enabled, version }) => ({
id,
name,

View File

@ -11,6 +11,7 @@ import type {
NextcloudSettings,
OnlyOfficeSettings,
RichTextSettings,
AiAssistantSettings,
OrgLLMSettings,
OrgSearchSettings,
OrgStorageQuotas,
@ -123,6 +124,17 @@ const DEFAULT_RICHTEXT: RichTextSettings = {
hocuspocus_url: "",
}
const DEFAULT_AI_ASSISTANT: AiAssistantSettings = {
enabled: false,
openwebui_internal_url: "",
public_path: "/ai",
embed_default_temporary: true,
default_model: "",
enabled_tools: ["mail", "drive", "contacts", "search"],
chat_sync_enabled: true,
chat_nc_path: "/.ultimail/ai/chats",
}
const DEFAULT_PLUGINS: PluginEntry[] = [
{
id: "mail-automation",
@ -155,10 +167,17 @@ const DEFAULT_PLUGINS: PluginEntry[] = [
{
id: "richtext-editor",
name: "Édition rich text TipTap",
description: "Éditeur texte collaboratif pour documents Word.",
description: "Édition rich text TipTap pour documents Word.",
enabled: true,
version: "1.0.0",
},
{
id: "ai-assistant",
name: "UltiAI",
description: "Assistant IA intégré avec tools mail, drive et contacts.",
enabled: false,
version: "1.0.0",
},
]
const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
@ -209,6 +228,7 @@ type OrgSettingsActions = {
setMailing: (patch: Partial<MailingSettings>) => void
setOnlyoffice: (patch: Partial<OnlyOfficeSettings>) => void
setRichtext: (patch: Partial<RichTextSettings>) => void
setAiAssistant: (patch: Partial<AiAssistantSettings>) => void
setAdministrators: (admins: Administrator[]) => void
addAdministrator: (admin: Administrator) => void
removeAdministrator: (id: string) => void
@ -231,6 +251,7 @@ type OrgSettingsActions = {
mailing: MailingSettings
onlyoffice: OnlyOfficeSettings
richtext: RichTextSettings
aiAssistant: AiAssistantSettings
plugins: PluginEntry[]
integrations: IntegrationEntry[]
}>, meta?: OrgSettingsMeta) => void
@ -251,6 +272,7 @@ export const useOrgSettingsStore = create<
mailing: MailingSettings
onlyoffice: OnlyOfficeSettings
richtext: RichTextSettings
aiAssistant: AiAssistantSettings
plugins: PluginEntry[]
integrations: IntegrationEntry[]
meta: OrgSettingsMeta | null
@ -270,6 +292,7 @@ export const useOrgSettingsStore = create<
mailing: DEFAULT_MAILING,
onlyoffice: DEFAULT_ONLYOFFICE,
richtext: DEFAULT_RICHTEXT,
aiAssistant: DEFAULT_AI_ASSISTANT,
plugins: DEFAULT_PLUGINS,
integrations: DEFAULT_INTEGRATIONS,
meta: null,
@ -299,6 +322,8 @@ export const useOrgSettingsStore = create<
set((s) => ({ onlyoffice: { ...s.onlyoffice, ...patch } })),
setRichtext: (patch) =>
set((s) => ({ richtext: { ...s.richtext, ...patch } })),
setAiAssistant: (patch) =>
set((s) => ({ aiAssistant: { ...s.aiAssistant, ...patch } })),
setAdministrators: (administrators) => set({ administrators }),
addAdministrator: (admin) =>
set((s) => ({ administrators: [...s.administrators, admin] })),

View File

@ -172,6 +172,17 @@ export type RichTextSettings = {
hocuspocus_url: string
}
export type AiAssistantSettings = {
enabled: boolean
openwebui_internal_url: string
public_path: string
embed_default_temporary: boolean
default_model: string
enabled_tools: string[]
chat_sync_enabled: boolean
chat_nc_path: string
}
export type PluginEntry = {
id: string
name: string
@ -203,6 +214,7 @@ export type OrgSettingsState = {
mailing: MailingSettings
onlyoffice: OnlyOfficeSettings
richtext: RichTextSettings
aiAssistant: AiAssistantSettings
plugins: PluginEntry[]
integrations: IntegrationEntry[]
}

View File

@ -34,6 +34,7 @@ export type AdminSettingsSectionId =
| "mailing"
| "onlyoffice"
| "richtext"
| "ai-assistant"
| "audit"
export type AdminSettingsNavItem = {
@ -150,6 +151,13 @@ export const ADMIN_SETTINGS_NAV: AdminSettingsNavItem[] = [
href: "/admin/settings/richtext",
icon: FileText,
},
{
id: "ai-assistant",
label: "UltiAI",
description: "Assistant IA intégré et tools suite",
href: "/admin/settings/ai-assistant",
icon: Bot,
},
{
id: "audit",
label: "Journal d'audit",

View File

@ -22,6 +22,7 @@ const GROUP_LABELS: Record<string, string> = {
authentik: "Authentik / OIDC",
nextcloud: "Nextcloud",
onlyoffice: "OnlyOffice",
ai_assistant: "UltiAI",
search: "Recherche",
immich: "Immich",
jitsi: "Jitsi Meet",

79
lib/ai/chat-context.ts Normal file
View File

@ -0,0 +1,79 @@
export type AiEmbedApp = "mail" | "drive" | "contacts" | "docs" | "standalone"
export type AiChatContext = {
app: AiEmbedApp
temporary?: boolean
messageId?: string
accountId?: string
drivePath?: string
fileId?: string
contactId?: string
subject?: string
snippet?: string
/** UltiDocs — titre affiché */
documentTitle?: string
/** Chemin du fichier source (.docx, .md…) si sidecar */
sourcePath?: string
/** Extrait texte du document */
documentExcerpt?: string
/** Sélection courante dans l'éditeur */
selectionText?: string
/** JSON TipTap (éventuellement tronqué) */
documentJson?: string
/** Prompt système additionnel (non sérialisé en URL) */
systemPromptExtra?: string
}
export type AiPostMessage =
| {
type: "ULTI_CONTEXT_UPDATE"
context: AiChatContext
systemPrompt?: string
}
| { type: "ULTI_DOCS_APPLY"; payload: unknown }
| { type: "ULTI_ASSISTANT_TEXT"; text: string }
| { type: "ULTI_THEME"; theme: "light" | "dark" }
| { type: "ULTI_OPEN_LINK"; href: string }
| { type: "ULTI_TOOL_RESULT"; payload: unknown }
export function buildEmbedSearchParams(context: AiChatContext): string {
const params = new URLSearchParams()
if (context.temporary !== false) params.set("temporary-chat", "true")
if (context.app) params.set("app", context.app)
if (context.messageId) params.set("message_id", context.messageId)
if (context.accountId) params.set("account_id", context.accountId)
if (context.drivePath) params.set("path", context.drivePath)
if (context.fileId) params.set("file_id", context.fileId)
if (context.contactId) params.set("contact_id", context.contactId)
if (context.subject) params.set("subject", context.subject)
if (context.snippet) params.set("snippet", context.snippet)
return params.toString()
}
export function systemPromptFromContext(context: AiChatContext): string {
const lines = [
"Tu es UltiAI, l'assistant intégré à la suite Ultimail (mail, drive, contacts).",
"Réponds en français sauf demande contraire. Utilise les tools disponibles pour agir sur les données utilisateur.",
]
if (context.app === "mail" && context.subject) {
lines.push(`Contexte mail — sujet: ${context.subject}`)
if (context.snippet) lines.push(`Extrait: ${context.snippet}`)
}
if (context.app === "drive" && context.drivePath) {
lines.push(`Contexte drive — fichier/dossier: ${context.drivePath}`)
}
if (context.app === "contacts" && context.contactId) {
lines.push(`Contexte contacts — fiche: ${context.contactId}`)
}
if (context.app === "docs") {
if (context.documentTitle) lines.push(`Document: ${context.documentTitle}`)
if (context.drivePath) lines.push(`Sidecar: ${context.drivePath}`)
if (context.sourcePath) lines.push(`Source: ${context.sourcePath}`)
if (context.selectionText) lines.push(`Sélection: ${context.selectionText}`)
if (context.documentExcerpt) lines.push(`Contenu:\n${context.documentExcerpt}`)
}
if (context.systemPromptExtra) {
lines.push("", context.systemPromptExtra)
}
return lines.join("\n")
}

72
lib/ai/docs-apply.ts Normal file
View File

@ -0,0 +1,72 @@
import type { Editor } from "@tiptap/react"
import { ensureMinimalTipTapDoc } from "@/lib/drive/richtext-content"
export type DocsApplyAction =
| { action: "insert_text"; text: string }
| { action: "replace_selection"; text: string }
| { action: "append_paragraph"; text: string }
| { action: "set_content"; document: Record<string, unknown> }
export function parseDocsApplyPayload(raw: unknown): DocsApplyAction | null {
if (!raw || typeof raw !== "object") return null
const obj = raw as Record<string, unknown>
const action = obj.action
if (action === "insert_text" && typeof obj.text === "string") {
return { action, text: obj.text }
}
if (action === "replace_selection" && typeof obj.text === "string") {
return { action, text: obj.text }
}
if (action === "append_paragraph" && typeof obj.text === "string") {
return { action, text: obj.text }
}
if (action === "set_content" && obj.document && typeof obj.document === "object") {
return {
action,
document: obj.document as Record<string, unknown>,
}
}
return null
}
/** Extrait un bloc ```ulti-docs-apply depuis une réponse assistant. */
export function extractDocsApplyFromMarkdown(text: string): DocsApplyAction | null {
const match = text.match(/```ulti-docs-apply\s*([\s\S]*?)```/i)
if (!match?.[1]) return null
try {
return parseDocsApplyPayload(JSON.parse(match[1].trim()))
} catch {
return null
}
}
export function applyDocsAction(editor: Editor, cmd: DocsApplyAction): boolean {
if (!editor.isEditable) return false
switch (cmd.action) {
case "insert_text":
return editor.chain().focus().insertContent(cmd.text).run()
case "replace_selection":
if (editor.state.selection.empty) {
return editor.chain().focus().insertContent(cmd.text).run()
}
return editor.chain().focus().deleteSelection().insertContent(cmd.text).run()
case "append_paragraph": {
const blocks = cmd.text
.split(/\n{2,}/)
.map((p) => p.trim())
.filter(Boolean)
.map((p) => ({
type: "paragraph",
content: [{ type: "text", text: p }],
}))
if (blocks.length === 0) return false
return editor.chain().focus().insertContentAt(editor.state.doc.content.size, blocks).run()
}
case "set_content": {
const doc = ensureMinimalTipTapDoc(cmd.document)
return editor.commands.setContent(doc)
}
default:
return false
}
}

92
lib/ai/docs-context.ts Normal file
View File

@ -0,0 +1,92 @@
import type { Editor } from "@tiptap/react"
import type { AiChatContext } from "@/lib/ai/chat-context"
import { TIPTAP_SYNTAX_GUIDE } from "@/lib/ai/tiptap-syntax-guide"
const MAX_CONTEXT_CHARS = 14_000
const MAX_JSON_CHARS = 8_000
export type DocsEditorSnapshot = {
documentPath: string
documentTitle: string
sourcePath?: string
plainText: string
selectionText: string
contentJson: Record<string, unknown>
contentJsonTruncated: string
}
export function snapshotDocsEditor(
editor: Editor,
meta: { path: string; title: string; sourcePath?: string }
): DocsEditorSnapshot {
const { from, to } = editor.state.selection
const plainText = editor.getText()
const selectionText =
from === to ? "" : editor.state.doc.textBetween(from, to, "\n", "\n")
const contentJson = editor.getJSON() as Record<string, unknown>
const jsonRaw = JSON.stringify(contentJson)
const contentJsonTruncated =
jsonRaw.length > MAX_JSON_CHARS
? jsonRaw.slice(0, MAX_JSON_CHARS) + "…"
: jsonRaw
return {
documentPath: meta.path,
documentTitle: meta.title,
sourcePath: meta.sourcePath,
plainText: truncate(plainText, MAX_CONTEXT_CHARS),
selectionText: truncate(selectionText, 4000),
contentJson,
contentJsonTruncated,
}
}
export function docsContextFromSnapshot(
snapshot: DocsEditorSnapshot,
temporary = true
): AiChatContext {
return {
app: "docs",
temporary,
drivePath: snapshot.documentPath,
documentTitle: snapshot.documentTitle,
sourcePath: snapshot.sourcePath,
documentExcerpt: snapshot.plainText,
selectionText: snapshot.selectionText || undefined,
documentJson: snapshot.contentJsonTruncated,
}
}
export function docsSystemPromptExtra(context: AiChatContext): string {
const lines = [
"Tu édites un document UltiDocs (TipTap). Tu peux lire le contenu ci-dessous et proposer des modifications.",
"Pour appliquer une modification côté éditeur, renvoie un bloc JSON fenced:",
'```ulti-docs-apply\n{ "action": "insert_text"|"replace_selection"|"append_paragraph"|"set_content", ... }\n```',
"",
TIPTAP_SYNTAX_GUIDE,
]
if (context.documentTitle) {
lines.push("", `Document: ${context.documentTitle}`)
}
if (context.drivePath) {
lines.push(`Chemin sidecar: ${context.drivePath}`)
}
if (context.sourcePath) {
lines.push(`Fichier source: ${context.sourcePath}`)
}
if (context.selectionText) {
lines.push("", "Sélection utilisateur:", context.selectionText)
}
if (context.documentExcerpt) {
lines.push("", "Contenu (texte):", context.documentExcerpt)
}
if (context.documentJson) {
lines.push("", "Contenu (JSON tronqué si long):", context.documentJson)
}
return lines.join("\n")
}
function truncate(value: string, max: number): string {
if (value.length <= max) return value
return value.slice(0, max) + "…"
}

View File

@ -0,0 +1,20 @@
/** Référence concise pour l'IA — structure TipTap / ProseMirror UltiDocs. */
export const TIPTAP_SYNTAX_GUIDE = `
UltiDocs utilise TipTap (ProseMirror). Le document est un JSON \`{ type: "doc", content: [...] }\`.
Blocs courants:
- paragraph: { type: "paragraph", content?: inline[] }
- heading: { type: "heading", attrs: { level: 1-6 }, content?: inline[] }
- bulletList / orderedList: { type: "bulletList", content: [listItem...] }
- listItem: { type: "listItem", content: [paragraph|...] }
- blockquote, codeBlock, horizontalRule, table (tableRow > tableCell)
Inline:
- text: { type: "text", text: "...", marks?: [{ type: "bold"|"italic"|"underline"|"link", attrs? }] }
Règles d'édition:
- Préserver la structure doc valide (toujours des blocs dans doc.content).
- Pour modifier: préférer remplacer la sélection ou insérer du texte/markdown converti en paragraphes.
- Ne pas inventer de nœuds custom (docsGraphic, table) sans connaître le schéma utiliser les tools docs_save avec JSON validé.
- Titres: un seul h1 recommandé en tête de document.
`.trim()

32
lib/ai/use-ai-panel.ts Normal file
View File

@ -0,0 +1,32 @@
"use client"
import { create } from "zustand"
import type { AiChatContext } from "@/lib/ai/chat-context"
type AiPanelState = {
open: boolean
context: AiChatContext
openPanel: (context?: Partial<AiChatContext>) => void
closePanel: () => void
setContext: (patch: Partial<AiChatContext>) => void
}
const DEFAULT_CONTEXT: AiChatContext = {
app: "standalone",
temporary: true,
}
export const useAiPanelStore = create<AiPanelState>((set) => ({
open: false,
context: DEFAULT_CONTEXT,
openPanel: (patch) =>
set((s) => ({
open: true,
context: { ...s.context, ...patch, temporary: patch?.temporary ?? true },
})),
closePanel: () => set({ open: false }),
setContext: (patch) =>
set((s) => ({
context: { ...s.context, ...patch },
})),
}))

View File

@ -0,0 +1,31 @@
"use client"
import { create } from "zustand"
type DocsAiPanelState = {
open: boolean
widthPx: number
toggle: () => void
openPanel: () => void
closePanel: () => void
setWidthPx: (width: number) => void
}
export const DOCS_AI_PANEL_DEFAULT_WIDTH_PX = 380
export const DOCS_AI_PANEL_MIN_WIDTH_PX = 280
export const DOCS_AI_PANEL_MAX_WIDTH_PX = 560
export const useDocsAiPanelStore = create<DocsAiPanelState>((set) => ({
open: false,
widthPx: DOCS_AI_PANEL_DEFAULT_WIDTH_PX,
toggle: () => set((s) => ({ open: !s.open })),
openPanel: () => set({ open: true }),
closePanel: () => set({ open: false }),
setWidthPx: (width) =>
set({
widthPx: Math.min(
DOCS_AI_PANEL_MAX_WIDTH_PX,
Math.max(DOCS_AI_PANEL_MIN_WIDTH_PX, width)
),
}),
}))

View File

@ -159,6 +159,17 @@ export type ApiOrgRichText = {
hocuspocus_url: string
}
export type ApiOrgAiAssistant = {
enabled: boolean
openwebui_internal_url: string
public_path: string
embed_default_temporary: boolean
default_model: string
enabled_tools: string[]
chat_sync_enabled: boolean
chat_nc_path: string
}
export type ApiOrgPlugin = {
id: string
name: string
@ -189,6 +200,7 @@ export type ApiOrgPolicy = {
mailing: ApiOrgMailing
onlyoffice: ApiOrgOnlyoffice
richtext?: ApiOrgRichText
ai_assistant?: ApiOrgAiAssistant
plugins: ApiOrgPlugin[]
integrations: ApiOrgIntegration[]
}
@ -222,6 +234,11 @@ export type ApiOrgEffective = {
enabled: boolean
public_url: string
}
ai_assistant?: {
enabled: boolean
openwebui_internal_url: string
public_path: string
}
identity_providers?: {
authentik_public_url: string
oauth_redirect_template: string

View File

@ -0,0 +1,67 @@
"use client"
import { useMutation, useQuery } from "@tanstack/react-query"
import { apiClient } from "@/lib/api/client"
import type { AiChatContext } from "@/lib/ai/chat-context"
export type AiConfig = {
enabled: boolean
public_path: string
embed_default_temporary: boolean
default_model: string
enabled_tools: string[]
chat_sync_enabled: boolean
}
export type AiQuota = {
requests_used_today: number
requests_limit: number
tokens_used_month: number
tokens_limit: number
requests_remaining: number
tokens_remaining: number
}
export type AiSessionResponse = {
session_id: string
embed_url: string
token_secret?: string
temporary: boolean
}
export function useAiConfig() {
return useQuery({
queryKey: ["ai", "config"],
queryFn: () => apiClient<AiConfig>("/ai/config"),
staleTime: 60_000,
})
}
export function useAiQuota(enabled = true) {
return useQuery({
queryKey: ["ai", "quota"],
queryFn: () => apiClient<AiQuota>("/ai/quota"),
enabled,
staleTime: 30_000,
})
}
export function useCreateAiSession() {
return useMutation({
mutationFn: (context: AiChatContext) =>
apiClient<AiSessionResponse>("/ai/sessions", {
method: "POST",
body: JSON.stringify({
app: context.app,
temporary: context.temporary ?? true,
message_id: context.messageId,
account_id: context.accountId,
drive_path: context.drivePath,
file_id: context.fileId,
contact_id: context.contactId,
subject: context.subject,
snippet: context.snippet,
}),
}),
})
}

View File

@ -253,7 +253,7 @@ export function useDriveMutations() {
})
const createFile = useMutation({
mutationFn: (body: { parent_path: string; name: string; kind: string }) =>
apiClient.post<{ path: string }>("/office/create", body),
apiClient.post<{ path: string; file_id?: number }>("/drive/files/new", body),
onSuccess: invalidate,
})

View File

@ -0,0 +1,32 @@
import type { Node as ProseMirrorNode } from "@tiptap/pm/model"
export function isDocsBlockType(typeName: string): boolean {
return typeName === "paragraph" || typeName === "heading"
}
export function getDocsBlockTargets(
doc: ProseMirrorNode,
from: number,
to: number
): Array<{ pos: number; node: ProseMirrorNode }> {
const targets: Array<{ pos: number; node: ProseMirrorNode }> = []
if (from === to) {
const $pos = doc.resolve(from)
for (let depth = $pos.depth; depth > 0; depth -= 1) {
const node = $pos.node(depth)
if (isDocsBlockType(node.type.name)) {
targets.push({ pos: $pos.before(depth), node })
break
}
}
return targets
}
doc.nodesBetween(from, to, (node, pos) => {
if (!isDocsBlockType(node.type.name)) return
targets.push({ pos, node })
})
return targets
}

View File

@ -0,0 +1,33 @@
import type { DocsExportSnapshot } from "@/lib/drive/docs-export-snapshot"
import { prepareContentForDocxExport } from "@/lib/drive/docs-export-prepare"
import { patchDocxPageSetup } from "@/lib/drive/docx-page-setup-export"
import { patchDocxHeaderFooter } from "@/lib/drive/docx-header-footer-export"
import { patchDocxAnchoredGraphics } from "@/lib/drive/docx-position-export"
export async function exportDocsToDocx(snapshot: DocsExportSnapshot): Promise<Blob> {
const { content, anchoredGraphics } = await prepareContentForDocxExport(
snapshot.body,
snapshot.paragraphStyles
)
const { generateDOCX } = await import("@docen/export-docx")
const generated = await generateDOCX(content, {
outputType: "uint8array",
title: snapshot.title,
})
let buffer: Uint8Array = generated as Uint8Array
if (snapshot.pageSetup) {
buffer = await patchDocxPageSetup(buffer, snapshot.pageSetup)
buffer = await patchDocxHeaderFooter(buffer, snapshot.pageSetup)
}
if (anchoredGraphics.length > 0) {
buffer = await patchDocxAnchoredGraphics(buffer, anchoredGraphics)
}
return new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
})
}

View File

@ -0,0 +1,23 @@
export function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
}
function baseNameWithoutExt(fileName: string): string {
const slash = fileName.lastIndexOf("/")
const base = slash >= 0 ? fileName.slice(slash + 1) : fileName
const dot = base.lastIndexOf(".")
return dot > 0 ? base.slice(0, dot) : base
}
export function exportFileName(sourceName: string, ext: string): string {
return `${baseNameWithoutExt(sourceName)}.${ext}`
}
export function baseNameFromSource(sourceName: string): string {
return baseNameWithoutExt(sourceName)
}

View File

@ -0,0 +1,255 @@
import type { DocParagraphStyleDefinition, DocParagraphStylesCatalog } from "@/lib/drive/docs-paragraph-styles"
import { resolveParagraphStyleDefinition } from "@/lib/drive/docs-paragraph-styles"
import { prepareTablesForDocxExport } from "@/lib/drive/docs-table-export"
import { parseGraphicAttrs, type DocsGraphicAttrs } from "@/lib/drive/docs-graphic-types"
import type { TipTapJSON } from "@/lib/drive/richtext-import"
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
async function resolveGraphicSrc(src: string | null): Promise<string | null> {
if (!src) return null
if (src.startsWith("data:")) return src
try {
const res = await fetch(src, { credentials: "include" })
if (!res.ok) return null
return blobToDataUrl(await res.blob())
} catch {
return null
}
}
function textMarksFromStyle(def: DocParagraphStyleDefinition): TipTapJSON[] {
const marks: TipTapJSON[] = []
if (def.bold) marks.push({ type: "bold" })
if (def.italic) marks.push({ type: "italic" })
if (def.underline) marks.push({ type: "underline" })
const textStyleAttrs: Record<string, unknown> = {}
if (def.fontFamily) textStyleAttrs.fontFamily = def.fontFamily
if (def.fontSizePx != null) textStyleAttrs.fontSize = `${def.fontSizePx}px`
if (def.color) textStyleAttrs.color = def.color
if (Object.keys(textStyleAttrs).length > 0) {
marks.push({ type: "textStyle", attrs: textStyleAttrs })
}
return marks
}
function applyMarksToTextNodes(node: TipTapJSON, marks: TipTapJSON[]): TipTapJSON {
if (node.type === "text") {
const existing = Array.isArray(node.marks) ? (node.marks as TipTapJSON[]) : []
return { ...node, marks: [...existing, ...marks] }
}
if (!Array.isArray(node.content)) return node
return {
...node,
content: node.content.map((child) =>
applyMarksToTextNodes(child as TipTapJSON, marks)
),
}
}
function nodeChildren(node: TipTapJSON): TipTapJSON[] {
return Array.isArray(node.content) ? (node.content as TipTapJSON[]) : []
}
export function applyParagraphStylesForDocxExport(
content: TipTapJSON,
catalog: DocParagraphStylesCatalog
): TipTapJSON {
const walk = (node: TipTapJSON): TipTapJSON => {
const type = node.type as string | undefined
const attrs = isRecord(node.attrs) ? { ...node.attrs } : undefined
if ((type === "paragraph" || type === "heading") && attrs) {
const styleId = typeof attrs.styleId === "string" ? attrs.styleId : null
if (styleId) {
const def = resolveParagraphStyleDefinition(catalog, styleId)
if (def) {
if (def.blockType === "heading" && def.level) {
return walk({
type: "heading",
attrs: {
level: def.level,
textAlign: def.textAlign ?? attrs.textAlign,
},
content: nodeChildren(node).map((child) =>
applyMarksToTextNodes(child, textMarksFromStyle(def))
),
})
}
const nextAttrs: Record<string, unknown> = { ...attrs }
delete nextAttrs.styleId
if (def.textAlign) nextAttrs.textAlign = def.textAlign
let next: TipTapJSON = {
...node,
attrs: nextAttrs,
content: nodeChildren(node).map((child) =>
applyMarksToTextNodes(child, textMarksFromStyle(def))
),
}
if (Array.isArray(next.content)) {
next = {
...next,
content: next.content.map((child) => walk(child as TipTapJSON)),
}
}
return next
}
}
}
if (Array.isArray(node.content)) {
return {
...node,
content: node.content.map((child) => walk(child as TipTapJSON)),
}
}
return node
}
return walk(content)
}
export type PreparedGraphicMeta = {
nodeIndex: number
attrs: DocsGraphicAttrs
}
async function graphicToImageNode(
attrs: Record<string, unknown>,
asInline: boolean
): Promise<TipTapJSON | null> {
const graphic = parseGraphicAttrs(attrs)
if (graphic.graphicType !== "image" || !graphic.src) {
if (graphic.graphicType === "shape" || graphic.graphicType === "gradient") {
return null
}
return null
}
const src = await resolveGraphicSrc(graphic.src)
if (!src) return null
const imageAttrs: Record<string, unknown> = {
src,
alt: graphic.alt || graphic.altTitle || "",
width: graphic.width,
height: graphic.height,
}
if (graphic.placement === "absolute" || graphic.positionMode === "fixed-on-page") {
imageAttrs.placement = "absolute"
imageAttrs.x = graphic.pageX || graphic.x
imageAttrs.y = graphic.pageY || graphic.y
imageAttrs.wrap = graphic.wrap
imageAttrs.floatSide = graphic.floatSide
imageAttrs.rotationDeg = graphic.rotationDeg
}
return { type: "image", attrs: imageAttrs }
}
export async function prepareGraphicsForDocxExport(
content: TipTapJSON
): Promise<{ content: TipTapJSON; anchoredGraphics: PreparedGraphicMeta[] }> {
const anchoredGraphics: PreparedGraphicMeta[] = []
let nodeIndex = 0
const walk = async (node: TipTapJSON): Promise<TipTapJSON | null> => {
const type = node.type as string | undefined
if (type === "docsGraphic" || type === "docsInlineGraphic") {
const attrs = isRecord(node.attrs) ? node.attrs : {}
const graphic = parseGraphicAttrs(attrs)
const usesPageLayer =
graphic.placement === "absolute" ||
graphic.positionMode === "fixed-on-page" ||
graphic.wrap === "behind" ||
graphic.wrap === "in-front"
if (usesPageLayer && graphic.src) {
const resolvedSrc = await resolveGraphicSrc(graphic.src)
anchoredGraphics.push({
nodeIndex,
attrs: resolvedSrc ? { ...graphic, src: resolvedSrc } : graphic,
})
nodeIndex += 1
return null
}
const imageNode = await graphicToImageNode(attrs, type === "docsInlineGraphic")
nodeIndex += 1
return imageNode
}
if (type === "image") {
const attrs = isRecord(node.attrs) ? { ...node.attrs } : {}
if (typeof attrs.src === "string") {
const resolved = await resolveGraphicSrc(attrs.src)
if (resolved) attrs.src = resolved
}
nodeIndex += 1
return { ...node, attrs }
}
if (Array.isArray(node.content)) {
const nextContent: TipTapJSON[] = []
for (const child of node.content) {
const next = await walk(child as TipTapJSON)
if (next) nextContent.push(next)
}
return { ...node, content: nextContent }
}
nodeIndex += 1
return node
}
const prepared = await walk(content)
return {
content: prepared ?? { type: "doc", content: [] },
anchoredGraphics,
}
}
export function stripEditorDecorations(content: TipTapJSON): TipTapJSON {
const walk = (node: TipTapJSON): TipTapJSON | null => {
if (node.type === "docsPageFlowSpacer") return null
if (!Array.isArray(node.content)) return node
const nextContent = node.content
.map((child) => walk(child as TipTapJSON))
.filter((child): child is TipTapJSON => child != null)
return { ...node, content: nextContent }
}
const result = walk(content)
return result ?? { type: "doc", content: [] }
}
export function prepareListsForDocxExport(content: TipTapJSON): TipTapJSON {
return content
}
export async function prepareContentForDocxExport(
content: TipTapJSON,
catalog: DocParagraphStylesCatalog
): Promise<{ content: TipTapJSON; anchoredGraphics: PreparedGraphicMeta[] }> {
let prepared = stripEditorDecorations(content)
prepared = applyParagraphStylesForDocxExport(prepared, catalog)
prepared = prepareListsForDocxExport(prepared)
prepared = prepareTablesForDocxExport(prepared)
return prepareGraphicsForDocxExport(prepared)
}

View File

@ -0,0 +1,43 @@
import type { Editor } from "@tiptap/react"
import {
resolveDocumentPageLayout,
type DocPageLayout,
type DocPageSetup,
} from "@/lib/drive/doc-page-setup"
import type { DocParagraphStylesCatalog } from "@/lib/drive/docs-paragraph-styles"
import type { PageFormatId } from "@/lib/drive/page-formats"
import type { TipTapJSON } from "@/lib/drive/richtext-import"
export type DocsExportSnapshot = {
title: string
sourceName: string
body: TipTapJSON
pageSetup: DocPageSetup | null
pageLayout: DocPageLayout
paragraphStyles: DocParagraphStylesCatalog
pageCount: number
getPageStackElement: () => HTMLElement | null
}
export function buildDocsExportSnapshot(params: {
editor: Editor | null
sourceName: string
title?: string
pageSetup: DocPageSetup | null
fallbackFormatId: PageFormatId
paragraphStyles: DocParagraphStylesCatalog
pageCount: number
getPageStackElement: () => HTMLElement | null
}): DocsExportSnapshot | null {
if (!params.editor) return null
return {
title: params.title ?? params.sourceName,
sourceName: params.sourceName,
body: params.editor.getJSON() as TipTapJSON,
pageSetup: params.pageSetup,
pageLayout: resolveDocumentPageLayout(params.pageSetup, params.fallbackFormatId),
paragraphStyles: params.paragraphStyles,
pageCount: Math.max(1, params.pageCount),
getPageStackElement: params.getPageStackElement,
}
}

View File

@ -0,0 +1,105 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { extractDocxHeaderFooter } from "@/lib/drive/docx-header-footer-import"
import { exportDocsToDocx } from "@/lib/drive/docs-docx-export"
import { applyParagraphStylesForDocxExport } from "@/lib/drive/docs-export-prepare"
import { buildDocsExportSnapshot } from "@/lib/drive/docs-export-snapshot"
import { defaultDocumentParagraphStyles } from "@/lib/drive/docs-paragraph-styles"
import { buildPageSetupFromDraft, defaultPageMarginsMm } from "@/lib/drive/doc-page-setup"
import { readUserPageSetupDefaults } from "@/lib/drive/docs-page-defaults"
import type { TipTapJSON } from "@/lib/drive/richtext-import"
import { resolveDocumentPageLayout } from "@/lib/drive/doc-page-setup"
function baseSnapshot(body: TipTapJSON, pageSetup = buildPageSetupFromDraft(readUserPageSetupDefaults("a4"), null)) {
return {
title: "Test",
sourceName: "test.docx",
body,
pageSetup,
pageLayout: resolveDocumentPageLayout(pageSetup, "a4"),
paragraphStyles: defaultDocumentParagraphStyles(),
pageCount: 1,
getPageStackElement: () => null,
}
}
describe("docs-export", () => {
it("applyParagraphStylesForDocxExport converts styleId to heading", () => {
const content: TipTapJSON = {
type: "doc",
content: [
{
type: "paragraph",
attrs: { styleId: "heading1" },
content: [{ type: "text", text: "Title" }],
},
],
}
const prepared = applyParagraphStylesForDocxExport(content, defaultDocumentParagraphStyles())
const block = (prepared.content as TipTapJSON[])[0]
assert.equal(block.type, "heading")
assert.equal((block.attrs as { level?: number }).level, 1)
})
it("exportDocsToDocx includes header/footer in archive", async () => {
const body: TipTapJSON = {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Corps du document" }],
},
],
}
const pageSetup = {
...buildPageSetupFromDraft(readUserPageSetupDefaults("a4"), null),
marginsMm: defaultPageMarginsMm(),
header: {
content: {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "En-tête test" }],
},
],
},
},
footer: {
content: {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Pied test" }],
},
],
},
},
}
const blob = await exportDocsToDocx(baseSnapshot(body, pageSetup))
const buffer = await blob.arrayBuffer()
const extracted = await extractDocxHeaderFooter(buffer)
assert.ok(extracted.header?.content)
assert.ok(extracted.footer?.content)
})
it("buildDocsExportSnapshot returns null without editor", () => {
assert.equal(
buildDocsExportSnapshot({
editor: null,
sourceName: "x.docx",
pageSetup: null,
fallbackFormatId: "a4",
paragraphStyles: defaultDocumentParagraphStyles(),
pageCount: 1,
getPageStackElement: () => null,
}),
null
)
})
})

View File

@ -1,5 +1,11 @@
import type { Editor } from "@tiptap/react"
import { exportTipTapToDocx, type TipTapJSON } from "@/lib/drive/richtext-import"
import type { DocsExportSnapshot } from "@/lib/drive/docs-export-snapshot"
import {
baseNameFromSource,
downloadBlob,
exportFileName,
} from "@/lib/drive/docs-export-download"
import type { TipTapJSON } from "@/lib/drive/richtext-import"
export type DocsDownloadFormat =
| "docx"
@ -23,53 +29,46 @@ export const DOCS_DOWNLOAD_FORMATS: { id: DocsDownloadFormat; label: string }[]
{ id: "md", label: "Markdown (.md)" },
]
function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
}
function baseNameWithoutExt(fileName: string): string {
const slash = fileName.lastIndexOf("/")
const base = slash >= 0 ? fileName.slice(slash + 1) : fileName
const dot = base.lastIndexOf(".")
return dot > 0 ? base.slice(0, dot) : base
}
export function exportFileName(sourceName: string, ext: string): string {
return `${baseNameWithoutExt(sourceName)}.${ext}`
}
export { downloadBlob, exportFileName } from "@/lib/drive/docs-export-download"
export async function exportDocsContent(
format: DocsDownloadFormat,
snapshot: DocsExportSnapshot | null,
editor: Editor | null,
sourceName: string
): Promise<"done" | "unsupported"> {
if (!editor) return "unsupported"
if (!editor && !snapshot) return "unsupported"
const content = editor.getJSON() as TipTapJSON
const base = baseNameWithoutExt(sourceName)
const base = baseNameFromSource(sourceName)
switch (format) {
case "docx": {
const blob = await exportTipTapToDocx(content)
if (!snapshot) return "unsupported"
const { exportDocsToDocx } = await import("@/lib/drive/docs-docx-export")
const blob = await exportDocsToDocx(snapshot)
downloadBlob(blob, exportFileName(sourceName, "docx"))
return "done"
}
case "pdf": {
if (!snapshot) return "unsupported"
const { exportDocsToPdf } = await import("@/lib/drive/docs-pdf-export")
await exportDocsToPdf(snapshot)
return "done"
}
case "txt": {
if (!editor) return "unsupported"
const text = editor.getText()
downloadBlob(new Blob([text], { type: "text/plain;charset=utf-8" }), `${base}.txt`)
return "done"
}
case "md": {
if (!editor) return "unsupported"
const text = editor.getText()
downloadBlob(new Blob([text], { type: "text/markdown;charset=utf-8" }), `${base}.md`)
return "done"
}
case "html": {
if (!editor) return "unsupported"
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${base}</title></head><body>${editor.getHTML()}</body></html>`
downloadBlob(new Blob([html], { type: "text/html;charset=utf-8" }), `${base}.html`)
return "done"
@ -78,3 +77,5 @@ export async function exportDocsContent(
return "unsupported"
}
}
export type { TipTapJSON }

View File

@ -0,0 +1,6 @@
import type { Editor } from "@tiptap/react"
import { applyDocsParagraphStyleById } from "@/lib/drive/docs-paragraph-style-apply"
export function applyDocsParagraphStyle(editor: Editor, styleId: string) {
applyDocsParagraphStyleById(editor, styleId)
}

Some files were not shown because too many files have changed in this diff Show More