parent
2a7c153748
commit
303b2b1074
@ -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
|
||||
|
||||
11
app/api/demo/richtext-save/route.ts
Normal file
11
app/api/demo/richtext-save/route.ts
Normal 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
50
app/chat/page.tsx
Normal 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'est pas activé. Activez le plugin dans l'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'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
18
app/demo/docs/page.tsx
Normal 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
18
app/demo/mail/page.tsx
Normal 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 />
|
||||
}
|
||||
20
app/drive/draw/[fileId]/edit/page.tsx
Normal file
20
app/drive/draw/[fileId]/edit/page.tsx
Normal 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} />
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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%);
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
13
app/page.tsx
13
app/page.tsx
@ -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 />
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
99
components/admin/settings/sections/ai-assistant-section.tsx
Normal file
99
components/admin/settings/sections/ai-assistant-section.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
55
components/ai/ai-chat-iframe.tsx
Normal file
55
components/ai/ai-chat-iframe.tsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
46
components/ai/ai-chat-panel.tsx
Normal file
46
components/ai/ai-chat-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
223
components/ai/docs-ai-panel.tsx
Normal file
223
components/ai/docs-ai-panel.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
51
components/demo/demo-docs-editor.tsx
Normal file
51
components/demo/demo-docs-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
496
components/demo/demo-mail-app.tsx
Normal file
496
components/demo/demo-mail-app.tsx
Normal 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)]">
|
||||
<{selected.fromEmail}>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
151
components/demo/demo-mail-data.ts
Normal file
151
components/demo/demo-mail-data.ts
Normal 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",
|
||||
},
|
||||
]
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
172
components/drive/public-ultidraw-editor.tsx
Normal file
172
components/drive/public-ultidraw-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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" />
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
160
components/drive/richtext/docs-drive-draw-picker-dialog.tsx
Normal file
160
components/drive/richtext/docs-drive-draw-picker-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
175
components/drive/richtext/docs-drive-image-picker-dialog.tsx
Normal file
175
components/drive/richtext/docs-drive-image-picker-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
components/drive/richtext/docs-editor-loading-shell.tsx
Normal file
63
components/drive/richtext/docs-editor-loading-shell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
159
components/drive/richtext/docs-excalidraw-editor.tsx
Normal file
159
components/drive/richtext/docs-excalidraw-editor.tsx
Normal 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'é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 }
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
203
components/drive/richtext/docs-format-menu-presets.tsx
Normal file
203
components/drive/richtext/docs-format-menu-presets.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1081
components/drive/richtext/docs-format-menu.tsx
Normal file
1081
components/drive/richtext/docs-format-menu.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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'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]}
|
||||
|
||||
@ -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>
|
||||
|
||||
117
components/drive/richtext/docs-graphic-draw-modal.tsx
Normal file
117
components/drive/richtext/docs-graphic-draw-modal.tsx
Normal 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)
|
||||
488
components/drive/richtext/docs-graphic-floating-toolbar.tsx
Normal file
488
components/drive/richtext/docs-graphic-floating-toolbar.tsx
Normal 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'image…
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={downloadImage} disabled={!attrs.src}>
|
||||
Télécharger l'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)
|
||||
183
components/drive/richtext/docs-graphic-layout-previews.tsx
Normal file
183
components/drive/richtext/docs-graphic-layout-previews.tsx
Normal 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
@ -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}
|
||||
|
||||
|
||||
1008
components/drive/richtext/docs-graphic-options-sidebar.tsx
Normal file
1008
components/drive/richtext/docs-graphic-options-sidebar.tsx
Normal file
File diff suppressed because it is too large
Load Diff
60
components/drive/richtext/docs-graphic-snap-guides.tsx
Normal file
60
components/drive/richtext/docs-graphic-snap-guides.tsx
Normal 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)
|
||||
@ -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'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]}
|
||||
|
||||
@ -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}
|
||||
|
||||
583
components/drive/richtext/docs-insert-menu.tsx
Normal file
583
components/drive/richtext/docs-insert-menu.tsx
Normal 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'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'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'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'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'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'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>
|
||||
)
|
||||
}
|
||||
57
components/drive/richtext/docs-insert-table-picker.tsx
Normal file
57
components/drive/richtext/docs-insert-table-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
171
components/drive/richtext/docs-line-spacing-dialog.tsx
Normal file
171
components/drive/richtext/docs-line-spacing-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
307
components/drive/richtext/docs-link-popover.tsx
Normal file
307
components/drive/richtext/docs-link-popover.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
components/drive/richtext/docs-list-start-dialog.tsx
Normal file
85
components/drive/richtext/docs-list-start-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
components/drive/richtext/docs-loading-splash.tsx
Normal file
64
components/drive/richtext/docs-loading-splash.tsx
Normal 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)
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
179
components/drive/richtext/docs-paragraph-style-ui.tsx
Normal file
179
components/drive/richtext/docs-paragraph-style-ui.tsx
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
246
components/drive/richtext/docs-table-context-menu.tsx
Normal file
246
components/drive/richtext/docs-table-context-menu.tsx
Normal 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'en-tête
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => editor.chain().focus().toggleHeaderColumn().run()}
|
||||
>
|
||||
Colonne d'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>
|
||||
)
|
||||
}
|
||||
317
components/drive/richtext/docs-table-floating-toolbar.tsx
Normal file
317
components/drive/richtext/docs-table-floating-toolbar.tsx
Normal 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'en-tête
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => editor.chain().focus().toggleHeaderColumn().run()}>
|
||||
Colonne d'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)
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
122
components/drive/ultidraw-chrome.tsx
Normal file
122
components/drive/ultidraw-chrome.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
312
components/drive/ultidraw-document.tsx
Normal file
312
components/drive/ultidraw-document.tsx
Normal 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'é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>
|
||||
)
|
||||
}
|
||||
175
components/drive/ultidraw-editor.tsx
Normal file
175
components/drive/ultidraw-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
162
components/landing/landing-data.ts
Normal file
162
components/landing/landing-data.ts
Normal 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" },
|
||||
]
|
||||
175
components/landing/landing-demo.tsx
Normal file
175
components/landing/landing-demo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
120
components/landing/landing-header.tsx
Normal file
120
components/landing/landing-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
210
components/landing/landing-hero.tsx
Normal file
210
components/landing/landing-hero.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
components/landing/landing-page.tsx
Normal file
48
components/landing/landing-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
components/landing/landing-reveal.tsx
Normal file
54
components/landing/landing-reveal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
358
components/landing/landing-sections.tsx
Normal file
358
components/landing/landing-sections.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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] })),
|
||||
|
||||
@ -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[]
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
79
lib/ai/chat-context.ts
Normal 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
72
lib/ai/docs-apply.ts
Normal 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
92
lib/ai/docs-context.ts
Normal 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) + "…"
|
||||
}
|
||||
20
lib/ai/tiptap-syntax-guide.ts
Normal file
20
lib/ai/tiptap-syntax-guide.ts
Normal 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
32
lib/ai/use-ai-panel.ts
Normal 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 },
|
||||
})),
|
||||
}))
|
||||
31
lib/ai/use-docs-ai-panel.ts
Normal file
31
lib/ai/use-docs-ai-panel.ts
Normal 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)
|
||||
),
|
||||
}),
|
||||
}))
|
||||
@ -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
|
||||
|
||||
67
lib/api/hooks/use-ai-queries.ts
Normal file
67
lib/api/hooks/use-ai-queries.ts
Normal 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,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
|
||||
|
||||
32
lib/drive/docs-block-targets.ts
Normal file
32
lib/drive/docs-block-targets.ts
Normal 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
|
||||
}
|
||||
33
lib/drive/docs-docx-export.ts
Normal file
33
lib/drive/docs-docx-export.ts
Normal 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",
|
||||
})
|
||||
}
|
||||
23
lib/drive/docs-export-download.ts
Normal file
23
lib/drive/docs-export-download.ts
Normal 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)
|
||||
}
|
||||
255
lib/drive/docs-export-prepare.ts
Normal file
255
lib/drive/docs-export-prepare.ts
Normal 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)
|
||||
}
|
||||
43
lib/drive/docs-export-snapshot.ts
Normal file
43
lib/drive/docs-export-snapshot.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
105
lib/drive/docs-export.test.ts
Normal file
105
lib/drive/docs-export.test.ts
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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 }
|
||||
|
||||
6
lib/drive/docs-format-actions.ts
Normal file
6
lib/drive/docs-format-actions.ts
Normal 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
Loading…
Reference in New Issue
Block a user