parent
2a7c153748
commit
303b2b1074
@ -21,3 +21,5 @@ OIDC_CLIENT_SECRET=changeme
|
|||||||
NEXT_PUBLIC_ONLYOFFICE_URL=http://localhost/office
|
NEXT_PUBLIC_ONLYOFFICE_URL=http://localhost/office
|
||||||
# Rich text editor (TipTap + Hocuspocus — docs texte)
|
# Rich text editor (TipTap + Hocuspocus — docs texte)
|
||||||
NEXT_PUBLIC_HOCUSPOCUS_URL=ws://localhost/collab
|
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"
|
"use client"
|
||||||
|
|
||||||
import { useParams } from "next/navigation"
|
import { useParams, useRouter } from "next/navigation"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Loader2, Lock } from "lucide-react"
|
import { Lock } from "lucide-react"
|
||||||
import {
|
import {
|
||||||
PublicShareChrome,
|
PublicShareChrome,
|
||||||
PublicShareViewPanel,
|
PublicShareViewPanel,
|
||||||
} from "@/components/drive/public-share-view"
|
} from "@/components/drive/public-share-view"
|
||||||
|
import { DocsLoadingSplash } from "@/components/drive/richtext/docs-loading-splash"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { usePublicShare } from "@/lib/api/hooks/use-public-share-queries"
|
import { usePublicShare } from "@/lib/api/hooks/use-public-share-queries"
|
||||||
import { folderPathFromPublicSegments } from "@/lib/api/public-share"
|
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() {
|
export default function PublicSharePage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
const token = String(params.token ?? "")
|
const token = String(params.token ?? "")
|
||||||
const pathSegments = params.path as string[] | undefined
|
const pathSegments = params.path as string[] | undefined
|
||||||
const path = folderPathFromPublicSegments(pathSegments)
|
const path = folderPathFromPublicSegments(pathSegments)
|
||||||
|
const pathHintsEditor = sharePathLooksLikeEditorFile(path)
|
||||||
|
|
||||||
const [passwordInput, setPasswordInput] = useState("")
|
const [passwordInput, setPasswordInput] = useState("")
|
||||||
const [password, setPassword] = useState<string | undefined>(undefined)
|
const [password, setPassword] = useState<string | undefined>(undefined)
|
||||||
|
const [redirectingToEditor, setRedirectingToEditor] = useState(false)
|
||||||
|
|
||||||
const { data, isLoading, isError, error, refetch, isFetching } = usePublicShare(
|
const { data, isLoading, isError, error, refetch, isFetching } = usePublicShare(
|
||||||
token,
|
token,
|
||||||
@ -30,12 +42,48 @@ export default function PublicSharePage() {
|
|||||||
const needsPassword =
|
const needsPassword =
|
||||||
isError && error instanceof Error && error.message === "password_required"
|
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(() => {
|
useEffect(() => {
|
||||||
if (password && typeof window !== "undefined") {
|
if (password && typeof window !== "undefined") {
|
||||||
sessionStorage.setItem(`public-share-pw:${token}`, password)
|
sessionStorage.setItem(`public-share-pw:${token}`, password)
|
||||||
}
|
}
|
||||||
}, [password, token])
|
}, [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) => {
|
const submitPassword = (event: React.FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const trimmed = passwordInput.trim()
|
const trimmed = passwordInput.trim()
|
||||||
@ -43,12 +91,19 @@ export default function PublicSharePage() {
|
|||||||
setPassword(trimmed)
|
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 (
|
return (
|
||||||
<PublicShareChrome>
|
<PublicShareChrome>
|
||||||
{isLoading || (isFetching && !data) ? (
|
{isLoading || (isFetching && !data) ? (
|
||||||
<div className="flex min-h-[40vh] items-center justify-center">
|
<DocsLoadingSplash phase="opening" />
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : needsPassword ? (
|
) : needsPassword ? (
|
||||||
<div className="mx-auto flex min-h-[40vh] max-w-md flex-col justify-center px-4 py-12">
|
<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">
|
<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 { useEffect, useState } from "react"
|
||||||
import { PublicOfficeEditor } from "@/components/drive/public-office-editor"
|
import { PublicOfficeEditor } from "@/components/drive/public-office-editor"
|
||||||
import { PublicRichTextEditor } from "@/components/drive/public-richtext-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 { filePathFromPublicEditSegments, readPublicShareRootType } from "@/lib/drive/public-share-url"
|
||||||
import type { PublicShareRootType } 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
|
return sessionStorage.getItem(`public-share-pw:${token}`) ?? undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const fileName = fileDisplayName ?? filePath.split("/").pop() ?? ""
|
||||||
|
|
||||||
|
const useUltidraw =
|
||||||
|
editorParam === "ultidraw" || shouldOpenInUltidrawEditor({ name: fileName })
|
||||||
|
|
||||||
const useRichText =
|
const useRichText =
|
||||||
editorParam === "richtext" ||
|
editorParam === "richtext" || shouldOpenInRichTextEditor({ name: fileName })
|
||||||
shouldOpenInRichTextEditor({
|
|
||||||
name: fileDisplayName ?? filePath.split("/").pop() ?? "",
|
if (useUltidraw) {
|
||||||
})
|
return (
|
||||||
|
<PublicUltidrawEditor
|
||||||
|
token={token}
|
||||||
|
filePath={filePath}
|
||||||
|
password={password}
|
||||||
|
returnTo={returnTo}
|
||||||
|
mode={mode}
|
||||||
|
fileDisplayName={fileDisplayName}
|
||||||
|
shareRoot={shareRoot}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (useRichText) {
|
if (useRichText) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
@import '../styles/onlyoffice-theme.css';
|
@import '../styles/onlyoffice-theme.css';
|
||||||
@import '../styles/richtext-editor.css';
|
@import '../styles/richtext-editor.css';
|
||||||
|
@import '../styles/docs-print.css';
|
||||||
|
@import '../styles/landing.css';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@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 {
|
@keyframes splash-loader-progress {
|
||||||
0% {
|
0% {
|
||||||
transform: translateX(-104%);
|
transform: translateX(-104%);
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import {
|
|||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
} from "@/components/ui/card"
|
} from "@/components/ui/card"
|
||||||
import { UltiMailLogo } from "@/components/ultimail-logo"
|
|
||||||
import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config"
|
import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@ -32,10 +31,23 @@ function LoginContent() {
|
|||||||
<div className="ultimail-login-card-frame mx-auto w-full max-w-sm">
|
<div className="ultimail-login-card-frame mx-auto w-full max-w-sm">
|
||||||
<Card className={LOGIN_CARD_CLASS}>
|
<Card className={LOGIN_CARD_CLASS}>
|
||||||
<CardHeader className="gap-4 px-0 text-center sm:px-0">
|
<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>
|
<CardDescription>
|
||||||
Connecte-toi avec ton compte Ulti (Authentik) pour accéder à la
|
Connecte-toi avec ton compte Ulti (Authentik) pour accéder à ta
|
||||||
messagerie.
|
suite : mail, drive, contacts et IA.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="text-sm text-destructive" role="alert">
|
<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 { useWebSocket } from "@/lib/api/ws"
|
||||||
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
||||||
import { FilePreviewDialog } from "@/components/drive/file-preview-dialog"
|
import { FilePreviewDialog } from "@/components/drive/file-preview-dialog"
|
||||||
|
import { AiChatPanel } from "@/components/ai/ai-chat-panel"
|
||||||
|
|
||||||
const MAIL_SETTINGS_PATH = "/mail/settings"
|
const MAIL_SETTINGS_PATH = "/mail/settings"
|
||||||
|
|
||||||
@ -191,6 +192,7 @@ function MailAppInner() {
|
|||||||
<RightPanel />
|
<RightPanel />
|
||||||
</div>
|
</div>
|
||||||
<ContactsPanel />
|
<ContactsPanel />
|
||||||
|
<AiChatPanel />
|
||||||
</div>
|
</div>
|
||||||
{!splitView ? (
|
{!splitView ? (
|
||||||
<MobileBottomBar
|
<MobileBottomBar
|
||||||
|
|||||||
13
app/page.tsx
13
app/page.tsx
@ -1,4 +1,15 @@
|
|||||||
|
import type { Metadata } from "next"
|
||||||
import { redirect } from "next/navigation"
|
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[] }>
|
type HomeSearchParams = Promise<{ mail?: string | string[] }>
|
||||||
|
|
||||||
@ -13,5 +24,5 @@ export default async function Home({
|
|||||||
if (mail && mail.length > 0) {
|
if (mail && mail.length > 0) {
|
||||||
redirect(`/mail/inbox/message/${encodeURIComponent(mail)}`)
|
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 { MailingSection } from "@/components/admin/settings/sections/mailing-section"
|
||||||
import { OnlyofficeSection } from "@/components/admin/settings/sections/onlyoffice-section"
|
import { OnlyofficeSection } from "@/components/admin/settings/sections/onlyoffice-section"
|
||||||
import { RichtextSection } from "@/components/admin/settings/sections/richtext-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"
|
import { AuditSection } from "@/components/admin/settings/sections/audit-section"
|
||||||
|
|
||||||
const SECTIONS: Record<AdminSettingsSectionId, React.ComponentType> = {
|
const SECTIONS: Record<AdminSettingsSectionId, React.ComponentType> = {
|
||||||
@ -38,6 +39,7 @@ const SECTIONS: Record<AdminSettingsSectionId, React.ComponentType> = {
|
|||||||
mailing: MailingSection,
|
mailing: MailingSection,
|
||||||
onlyoffice: OnlyofficeSection,
|
onlyoffice: OnlyofficeSection,
|
||||||
richtext: RichtextSection,
|
richtext: RichtextSection,
|
||||||
|
"ai-assistant": AiAssistantSection,
|
||||||
audit: AuditSection,
|
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,
|
useSessionGuardStore,
|
||||||
} from "@/lib/auth/session-guard-store"
|
} 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_LEAD_MS = 5 * 60 * 1000
|
||||||
const REFRESH_CHECK_MS = 60 * 1000
|
const REFRESH_CHECK_MS = 60 * 1000
|
||||||
|
|
||||||
function isPublicPath(pathname: string) {
|
function isPublicPath(pathname: string) {
|
||||||
|
if (pathname === "/") return true
|
||||||
if (pathname.startsWith("/drive/s/")) return true
|
if (pathname.startsWith("/drive/s/")) return true
|
||||||
return PUBLIC_PREFIXES.some(
|
return PUBLIC_PREFIXES.some(
|
||||||
(prefix) => pathname === prefix || pathname.startsWith(prefix)
|
(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 { useEffect, useLayoutEffect, type ReactNode } from "react"
|
||||||
import { DriveSidebar } from "@/components/drive/drive-sidebar"
|
import { DriveSidebar } from "@/components/drive/drive-sidebar"
|
||||||
|
import { AiChatPanel } from "@/components/ai/ai-chat-panel"
|
||||||
import { FilePreviewDialog } from "@/components/drive/file-preview-dialog"
|
import { FilePreviewDialog } from "@/components/drive/file-preview-dialog"
|
||||||
import { ShareDialog } from "@/components/drive/share-dialog"
|
import { ShareDialog } from "@/components/drive/share-dialog"
|
||||||
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
|
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>
|
<div className="flex min-w-0 flex-1 flex-col bg-app-canvas" data-drive-main-column>{children}</div>
|
||||||
<ShareDialog />
|
<ShareDialog />
|
||||||
<FilePreviewDialog />
|
<FilePreviewDialog />
|
||||||
|
<AiChatPanel />
|
||||||
</div>
|
</div>
|
||||||
</SuiteThemeShell>
|
</SuiteThemeShell>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
FolderUp,
|
FolderUp,
|
||||||
|
PenLine,
|
||||||
Presentation,
|
Presentation,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@ -125,6 +126,11 @@ export function DriveNewSheet({
|
|||||||
label="Présentation"
|
label="Présentation"
|
||||||
onClick={() => pick("presentation")}
|
onClick={() => pick("presentation")}
|
||||||
/>
|
/>
|
||||||
|
<SheetAction
|
||||||
|
icon={<PenLine className="text-violet-600" />}
|
||||||
|
label="Dessin"
|
||||||
|
onClick={() => pick("drawing")}
|
||||||
|
/>
|
||||||
<SheetAction
|
<SheetAction
|
||||||
icon={<FolderPlus className="text-amber-500" />}
|
icon={<FolderPlus className="text-amber-500" />}
|
||||||
label="Dossier"
|
label="Dossier"
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
FolderUp,
|
FolderUp,
|
||||||
|
PenLine,
|
||||||
Plus,
|
Plus,
|
||||||
Presentation,
|
Presentation,
|
||||||
Upload,
|
Upload,
|
||||||
@ -94,6 +95,13 @@ export function DriveNewMenu({ parentPath }: { parentPath: string }) {
|
|||||||
<Presentation className="text-amber-600" />
|
<Presentation className="text-amber-600" />
|
||||||
Présentation
|
Présentation
|
||||||
</DropdownMenuItem>
|
</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")}>
|
<DropdownMenuItem className={DRIVE_NEW_MENU_ITEM_CLASS} onClick={() => pickKind("folder")}>
|
||||||
<FolderPlus className="text-amber-500" />
|
<FolderPlus className="text-amber-500" />
|
||||||
Dossier
|
Dossier
|
||||||
|
|||||||
@ -3,8 +3,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Button } from "@/components/ui/button"
|
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 { 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 { displayFileBaseName } from "@/lib/drive/display-file-name"
|
||||||
import { resolvePublicShareEditReturnTo, shouldShowPublicShareEditorBack } from "@/lib/drive/public-share-url"
|
import { resolvePublicShareEditReturnTo, shouldShowPublicShareEditorBack } from "@/lib/drive/public-share-url"
|
||||||
import type { PublicShareRootType } 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]
|
[title, backHref, showBack, resolvedMode, guest.color, guest.guestName]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { documentLoading, documentPhase, onDocumentLoadingChange } = useDocsEditorLoadingState(
|
||||||
|
session?.roomId ?? filePath
|
||||||
|
)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-8">
|
<div className="flex h-full flex-col items-center justify-center gap-4 p-8">
|
||||||
@ -158,12 +166,14 @@ export function PublicRichTextEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh min-h-0 flex-col">
|
<DocsEditorLoadingShell
|
||||||
{!session ? (
|
title={title}
|
||||||
<div className="flex h-full items-center justify-center">
|
resolvingFile={false}
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
awaitingSession={!session}
|
||||||
</div>
|
documentLoading={Boolean(session) && documentLoading}
|
||||||
) : (
|
documentPhase={documentPhase}
|
||||||
|
>
|
||||||
|
{session ? (
|
||||||
<RichTextDocumentEditor
|
<RichTextDocumentEditor
|
||||||
session={session}
|
session={session}
|
||||||
mode={resolvedMode}
|
mode={resolvedMode}
|
||||||
@ -172,8 +182,10 @@ export function PublicRichTextEditor({
|
|||||||
fetchSourceBytes={fetchSourceBytes}
|
fetchSourceBytes={fetchSourceBytes}
|
||||||
importApi={importApi}
|
importApi={importApi}
|
||||||
chrome={chrome}
|
chrome={chrome}
|
||||||
|
deferSplash
|
||||||
|
onLoadingChange={onDocumentLoadingChange}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</DocsEditorLoadingShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import Link from "next/link"
|
|||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useEffect, useState, type ReactNode } from "react"
|
import { useEffect, useState, type ReactNode } from "react"
|
||||||
import { ChevronRight, Download, FolderOpen, Loader2, Lock } from "lucide-react"
|
import { ChevronRight, Download, FolderOpen, Loader2, Lock } from "lucide-react"
|
||||||
|
import { DocsLoadingSplash } from "@/components/drive/richtext/docs-loading-splash"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { PublicShareFolderView } from "@/components/drive/public-share-folder-view"
|
import { PublicShareFolderView } from "@/components/drive/public-share-folder-view"
|
||||||
import { FilePreviewDialog } from "@/components/drive/file-preview-dialog"
|
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 { drivePreviewKind, isSvgFile } from "@/lib/drive/drive-preview"
|
||||||
import { SvgPreviewViewer } from "@/components/drive/svg-preview-viewer"
|
import { SvgPreviewViewer } from "@/components/drive/svg-preview-viewer"
|
||||||
import { PUBLIC_SHARE_INSET_X } from "@/lib/drive/drive-chrome-classes"
|
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 {
|
import {
|
||||||
sharePermCanEdit,
|
sharePermCanEdit,
|
||||||
} from "@/lib/drive/drive-share-permissions"
|
} from "@/lib/drive/drive-share-permissions"
|
||||||
@ -227,13 +232,20 @@ export function PublicShareViewPanel({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!file) return
|
if (!file) return
|
||||||
const isEditorFile = shouldOpenInRichTextEditor(file) || shouldOpenInOnlyOffice(file)
|
const isEditorFile =
|
||||||
|
shouldOpenInRichTextEditor(file) ||
|
||||||
|
shouldOpenInUltidrawEditor(file) ||
|
||||||
|
shouldOpenInOnlyOffice(file)
|
||||||
if (!isEditorFile) return
|
if (!isEditorFile) return
|
||||||
const returnTo =
|
const returnTo =
|
||||||
typeof window !== "undefined"
|
typeof window !== "undefined"
|
||||||
? window.location.pathname + window.location.search
|
? window.location.pathname + window.location.search
|
||||||
: undefined
|
: undefined
|
||||||
const editor = shouldOpenInRichTextEditor(file) ? "richtext" : "office"
|
const editor = shouldOpenInUltidrawEditor(file)
|
||||||
|
? "ultidraw"
|
||||||
|
: shouldOpenInRichTextEditor(file)
|
||||||
|
? "richtext"
|
||||||
|
: "office"
|
||||||
router.replace(
|
router.replace(
|
||||||
buildPublicShareEditHref(
|
buildPublicShareEditHref(
|
||||||
token,
|
token,
|
||||||
@ -259,12 +271,13 @@ export function PublicShareViewPanel({
|
|||||||
anchor.remove()
|
anchor.remove()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file && (shouldOpenInRichTextEditor(file) || shouldOpenInOnlyOffice(file))) {
|
if (
|
||||||
return (
|
file &&
|
||||||
<div className="flex min-h-[40vh] items-center justify-center">
|
(shouldOpenInRichTextEditor(file) ||
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
shouldOpenInUltidrawEditor(file) ||
|
||||||
</div>
|
shouldOpenInOnlyOffice(file))
|
||||||
)
|
) {
|
||||||
|
return <DocsLoadingSplash phase="opening" title={file.name} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 { toast } from "sonner"
|
||||||
import { DocsChrome } from "@/components/drive/richtext/docs-chrome"
|
import { DocsChrome } from "@/components/drive/richtext/docs-chrome"
|
||||||
import { DocsEditorWorkspace } from "@/components/drive/richtext/docs-editor-workspace"
|
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 { DocsStatusBar } from "@/components/drive/richtext/docs-page-view"
|
||||||
import { DocsToolbar } from "@/components/drive/richtext/docs-toolbar"
|
import { DocsToolbar } from "@/components/drive/richtext/docs-toolbar"
|
||||||
import { buildRichTextExtensions, RICHTEXT_EDITOR_CLASS } from "@/lib/drive/richtext-extensions"
|
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 type { DriveShare, DriveFileInfo } from "@/lib/api/types"
|
||||||
import { useDocsEditMenu } from "@/lib/drive/use-docs-edit-menu"
|
import { useDocsEditMenu } from "@/lib/drive/use-docs-edit-menu"
|
||||||
import { useDocsFileMenu } from "@/lib/drive/use-docs-file-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 type { DocsViewMenuActions, DocsViewMenuState } from "@/components/drive/richtext/docs-view-menu"
|
||||||
import { useDocsViewSettings } from "@/lib/drive/docs-view-settings"
|
import { useDocsViewSettings } from "@/lib/drive/docs-view-settings"
|
||||||
import { useDocsKeyboardShortcutsStore } from "@/lib/stores/docs-keyboard-shortcuts-store"
|
import { useDocsKeyboardShortcutsStore } from "@/lib/stores/docs-keyboard-shortcuts-store"
|
||||||
@ -29,10 +32,21 @@ import {
|
|||||||
type DocPageSetup,
|
type DocPageSetup,
|
||||||
} from "@/lib/drive/doc-page-setup"
|
} from "@/lib/drive/doc-page-setup"
|
||||||
import { readUserPageSetupDefaults } from "@/lib/drive/docs-page-defaults"
|
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 { importFileToTipTap } from "@/lib/drive/richtext-import"
|
||||||
import { migrateBase64ImagesInContent } from "@/lib/drive/docs-graphic-assets"
|
import { migrateBase64ImagesInContent } from "@/lib/drive/docs-graphic-assets"
|
||||||
import { isUltidocPath } from "@/lib/drive/richtext-formats"
|
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"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const SAVE_DEBOUNCE_MS = 2000
|
const SAVE_DEBOUNCE_MS = 2000
|
||||||
@ -68,6 +82,8 @@ export function RichTextDocumentEditor({
|
|||||||
fetchSourceBytes,
|
fetchSourceBytes,
|
||||||
importApi,
|
importApi,
|
||||||
chrome,
|
chrome,
|
||||||
|
deferSplash = false,
|
||||||
|
onLoadingChange,
|
||||||
}: {
|
}: {
|
||||||
session: RichTextSessionResponse
|
session: RichTextSessionResponse
|
||||||
mode: "edit" | "view"
|
mode: "edit" | "view"
|
||||||
@ -81,6 +97,8 @@ export function RichTextDocumentEditor({
|
|||||||
pageSetup?: DocPageSetup | null
|
pageSetup?: DocPageSetup | null
|
||||||
}) => Promise<void>
|
}) => Promise<void>
|
||||||
chrome?: RichTextDocsChromeProps
|
chrome?: RichTextDocsChromeProps
|
||||||
|
deferSplash?: boolean
|
||||||
|
onLoadingChange?: (loading: boolean, phase: DocsLoadingPhase) => void
|
||||||
}) {
|
}) {
|
||||||
const editable = mode === "edit"
|
const editable = mode === "edit"
|
||||||
const collaboration = session.collaboration && Boolean(session.wsUrl && session.token)
|
const collaboration = session.collaboration && Boolean(session.wsUrl && session.token)
|
||||||
@ -96,6 +114,9 @@ export function RichTextDocumentEditor({
|
|||||||
const [importDone, setImportDone] = useState(!session.importRequired)
|
const [importDone, setImportDone] = useState(!session.importRequired)
|
||||||
const [contentImportPending, setContentImportPending] = useState(session.importRequired)
|
const [contentImportPending, setContentImportPending] = useState(session.importRequired)
|
||||||
const [importedContent, setImportedContent] = useState<Record<string, unknown> | null>(null)
|
const [importedContent, setImportedContent] = useState<Record<string, unknown> | null>(null)
|
||||||
|
const [documentParagraphStyles, setDocumentParagraphStyles] = useState<DocParagraphStylesCatalog>(
|
||||||
|
() => session.paragraphStyles ?? defaultDocumentParagraphStyles()
|
||||||
|
)
|
||||||
const [documentPageSetup, setDocumentPageSetup] = useState<DocPageSetup | null>(
|
const [documentPageSetup, setDocumentPageSetup] = useState<DocPageSetup | null>(
|
||||||
session.pageSetup ?? null
|
session.pageSetup ?? null
|
||||||
)
|
)
|
||||||
@ -127,6 +148,7 @@ export function RichTextDocumentEditor({
|
|||||||
toggleShowNonPrintableChars,
|
toggleShowNonPrintableChars,
|
||||||
} = useDocsViewSettings()
|
} = useDocsViewSettings()
|
||||||
const shellRef = useRef<HTMLDivElement>(null)
|
const shellRef = useRef<HTMLDivElement>(null)
|
||||||
|
const getPageStackElementRef = useRef<() => HTMLElement | null>(() => null)
|
||||||
const presenceUsers = useCollabPresence(provider, { name: userName, color: userColor })
|
const presenceUsers = useCollabPresence(provider, { name: userName, color: userColor })
|
||||||
const pageLayout = useMemo(
|
const pageLayout = useMemo(
|
||||||
() => resolveDocumentPageLayout(documentPageSetup, settings.pageFormatId),
|
() => resolveDocumentPageLayout(documentPageSetup, settings.pageFormatId),
|
||||||
@ -377,12 +399,10 @@ export function RichTextDocumentEditor({
|
|||||||
: await (await apiClient.getBlob(driveDownloadApiPath(source))).arrayBuffer()
|
: await (await apiClient.getBlob(driveDownloadApiPath(source))).arrayBuffer()
|
||||||
const imported = await importFileToTipTap(source.split("/").pop() ?? "file.docx", buf)
|
const imported = await importFileToTipTap(source.split("/").pop() ?? "file.docx", buf)
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (isEmptyTipTapDoc(imported.content as Record<string, unknown>)) {
|
const content = ensureMinimalTipTapDoc(imported.content as Record<string, unknown>)
|
||||||
throw new Error("Le fichier source n'a produit aucun contenu importable")
|
|
||||||
}
|
|
||||||
const payload = {
|
const payload = {
|
||||||
source_path: source,
|
source_path: source,
|
||||||
content: imported.content,
|
content,
|
||||||
pageSetup: imported.pageSetup ?? undefined,
|
pageSetup: imported.pageSetup ?? undefined,
|
||||||
}
|
}
|
||||||
if (importApi) {
|
if (importApi) {
|
||||||
@ -397,7 +417,7 @@ export function RichTextDocumentEditor({
|
|||||||
window.location.reload()
|
window.location.reload()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setImportedContent(imported.content as Record<string, unknown>)
|
setImportedContent(content)
|
||||||
setContentImportPending(false)
|
setContentImportPending(false)
|
||||||
setImportDone(true)
|
setImportDone(true)
|
||||||
purgeReimportingRef.current = false
|
purgeReimportingRef.current = false
|
||||||
@ -454,44 +474,55 @@ export function RichTextDocumentEditor({
|
|||||||
}
|
}
|
||||||
}, [collaboration, importDone, session.roomId, session.token, session.wsUrl, ydoc])
|
}, [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(
|
const scheduleSave = useCallback(
|
||||||
(json: Record<string, unknown>) => {
|
(json: Record<string, unknown>, options?: { immediate?: boolean }) => {
|
||||||
if (!editable || collaboration) return
|
if (!editable || collaboration) return
|
||||||
if (saveTimer.current) clearTimeout(saveTimer.current)
|
if (saveTimer.current) clearTimeout(saveTimer.current)
|
||||||
if (saveStatusRef.current !== "saving") {
|
if (saveStatusRef.current !== "saving") {
|
||||||
reportSaveStatus("saving")
|
reportSaveStatus("saving")
|
||||||
}
|
}
|
||||||
saveTimer.current = setTimeout(() => {
|
const runSave = () => {
|
||||||
void (async () => {
|
void persistDocument(json)
|
||||||
let content = json
|
.then(() => reportSaveStatus("saved"))
|
||||||
try {
|
.catch(() => reportSaveStatus("error"))
|
||||||
content = await migrateBase64ImagesInContent(json, async ({ dataUrl }) => {
|
}
|
||||||
const res = await apiClient.post<{ assetId: string; url: string }>(
|
if (options?.immediate) {
|
||||||
"/richtext/assets",
|
runSave()
|
||||||
{ path: session.canonicalPath, dataUrl }
|
return
|
||||||
)
|
}
|
||||||
return { assetId: res.assetId, url: res.url }
|
saveTimer.current = setTimeout(runSave, SAVE_DEBOUNCE_MS)
|
||||||
})
|
|
||||||
} 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)
|
|
||||||
},
|
},
|
||||||
[collaboration, editable, reportSaveStatus, session.canonicalPath, session.saveUrl]
|
[collaboration, editable, persistDocument, reportSaveStatus]
|
||||||
)
|
)
|
||||||
|
|
||||||
const collabReady = !collaboration || (Boolean(provider) && collabSynced)
|
const collabReady = !collaboration || (Boolean(provider) && collabSynced)
|
||||||
@ -531,6 +562,26 @@ export function RichTextDocumentEditor({
|
|||||||
[editorEnabled, extensions, collaboration, markCollabDirty, scheduleSave]
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!editor || editor.isDestroyed) return
|
if (!editor || editor.isDestroyed) return
|
||||||
|
|
||||||
@ -556,11 +607,86 @@ export function RichTextDocumentEditor({
|
|||||||
}
|
}
|
||||||
}, [editor, settings.spellcheck])
|
}, [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({
|
const fileMenu = useDocsFileMenu({
|
||||||
file: chrome?.file,
|
file: chrome?.file,
|
||||||
editor,
|
editor,
|
||||||
pageSetup: documentPageSetup,
|
pageSetup: documentPageSetup,
|
||||||
fallbackFormatId: settings.pageFormatId,
|
fallbackFormatId: settings.pageFormatId,
|
||||||
|
getExportSnapshot,
|
||||||
onPageSetupApply: (setup) => {
|
onPageSetupApply: (setup) => {
|
||||||
documentPageSetupRef.current = setup
|
documentPageSetupRef.current = setup
|
||||||
schedulePageSetupPatch(setup, { immediate: true })
|
schedulePageSetupPatch(setup, { immediate: true })
|
||||||
@ -572,9 +698,10 @@ export function RichTextDocumentEditor({
|
|||||||
disabled: !editable,
|
disabled: !editable,
|
||||||
})
|
})
|
||||||
|
|
||||||
const editMenu = useDocsEditMenu({
|
const formatMenu = useDocsFormatMenu({
|
||||||
editor,
|
editor,
|
||||||
disabled: !editable,
|
disabled: !editable,
|
||||||
|
onPageSetup: fileMenu.actions.onPageSetup,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleFullscreen = useCallback(() => {
|
const handleFullscreen = useCallback(() => {
|
||||||
@ -622,15 +749,34 @@ export function RichTextDocumentEditor({
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const closeDocsAiPanel = useDocsAiPanelStore((s) => s.closePanel)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
closeDocsAiPanel()
|
||||||
|
}, [session.canonicalPath, closeDocsAiPanel])
|
||||||
|
|
||||||
const chromeProps = chrome
|
const chromeProps = chrome
|
||||||
? {
|
? {
|
||||||
...chrome,
|
...chrome,
|
||||||
|
trailing: (
|
||||||
|
<>
|
||||||
|
{chrome.trailing}
|
||||||
|
<DocsAiPanelToggle />
|
||||||
|
</>
|
||||||
|
),
|
||||||
fileMenuActions: fileMenu.actions,
|
fileMenuActions: fileMenu.actions,
|
||||||
fileMenuDialogs: fileMenu.dialogs,
|
fileMenuDialogs: fileMenu.dialogs,
|
||||||
fileMenuDisabled: fileMenu.disabled,
|
fileMenuDisabled: fileMenu.disabled,
|
||||||
editMenuActions: editMenu.actions,
|
editMenuActions: editMenu.actions,
|
||||||
editMenuState: editMenu.state,
|
editMenuState: editMenu.state,
|
||||||
editMenuDisabled: editMenu.disabled,
|
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,
|
viewMenuActions,
|
||||||
viewMenuState,
|
viewMenuState,
|
||||||
viewMenuDisabled: false,
|
viewMenuDisabled: false,
|
||||||
@ -716,6 +862,20 @@ export function RichTextDocumentEditor({
|
|||||||
settings.pageFormatId,
|
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) {
|
if (collabError) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center p-6 text-sm text-destructive">
|
<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) {
|
if (!deferSplash && documentLoading) {
|
||||||
const statusText =
|
|
||||||
contentImportPending && !importDone
|
|
||||||
? "Import du document…"
|
|
||||||
: session.importRequired && !importDone
|
|
||||||
? "Import du document…"
|
|
||||||
: collaboration && !collabSynced
|
|
||||||
? "Connexion à la collaboration…"
|
|
||||||
: "Connexion…"
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<DocsLoadingSplash
|
||||||
{chromeProps && !settings.chromeCollapsed ? (
|
phase={loadingPhase}
|
||||||
<DocsChrome
|
title={chromeProps?.title}
|
||||||
{...chromeProps}
|
/>
|
||||||
saveStatus={saveStatus}
|
|
||||||
presenceUsers={presenceUsers}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
|
||||||
{statusText}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deferSplash && documentLoading) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<DocsParagraphStylesProvider value={paragraphStylesContextValue}>
|
||||||
<div
|
<div
|
||||||
ref={shellRef}
|
ref={shellRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -765,41 +918,54 @@ export function RichTextDocumentEditor({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{chrome ? (
|
{chrome ? (
|
||||||
<DocsEditorWorkspace
|
<div className="flex min-h-0 flex-1 flex-row">
|
||||||
editor={editor}
|
<DocsEditorWorkspace
|
||||||
pageLayout={pageLayout}
|
editor={editor}
|
||||||
zoom={settings.zoom}
|
pageLayout={pageLayout}
|
||||||
editable={editable && settings.editorMode !== "view"}
|
zoom={settings.zoom}
|
||||||
showLayout={settings.showLayout}
|
editable={editable && settings.editorMode !== "view"}
|
||||||
showRuler={settings.showRuler}
|
showLayout={settings.showLayout}
|
||||||
showNonPrintableChars={settings.showNonPrintableChars}
|
showRuler={settings.showRuler}
|
||||||
editorMode={settings.editorMode}
|
showNonPrintableChars={settings.showNonPrintableChars}
|
||||||
outlineExpanded={settings.outlineSidebarExpanded}
|
editorMode={settings.editorMode}
|
||||||
onToggleOutline={toggleOutlineSidebarExpanded}
|
outlineExpanded={settings.outlineSidebarExpanded}
|
||||||
onPageCountChange={handlePageCountChange}
|
onToggleOutline={toggleOutlineSidebarExpanded}
|
||||||
onCurrentPageChange={handleCurrentPageChange}
|
onPageCountChange={handlePageCountChange}
|
||||||
onRegionContentChange={handleRegionContentChange}
|
onCurrentPageChange={handleCurrentPageChange}
|
||||||
onPageSetupChange={handlePageSetupPatch}
|
onRegionContentChange={handleRegionContentChange}
|
||||||
onRegionEditorChange={setRegionEditor}
|
onPageSetupChange={handlePageSetupPatch}
|
||||||
toolbarShellClassName={
|
onRegionEditorChange={setRegionEditor}
|
||||||
settings.chromeCollapsed ? "docs-toolbar-shell--collapsed" : undefined
|
onPageStackReady={(getPageStack) => {
|
||||||
}
|
getPageStackElementRef.current = getPageStack
|
||||||
toolbar={
|
}}
|
||||||
editable ? (
|
toolbarShellClassName={
|
||||||
<DocsToolbar
|
settings.chromeCollapsed ? "docs-toolbar-shell--collapsed" : undefined
|
||||||
editor={regionEditor ?? editor}
|
}
|
||||||
zoom={settings.zoom}
|
toolbar={
|
||||||
onZoomChange={setZoom}
|
editable ? (
|
||||||
spellcheck={settings.spellcheck}
|
<DocsToolbar
|
||||||
onToggleSpellcheck={toggleSpellcheck}
|
editor={regionEditor ?? editor}
|
||||||
showChromeToggle={Boolean(chrome)}
|
zoom={settings.zoom}
|
||||||
chromeCollapsed={settings.chromeCollapsed}
|
onZoomChange={setZoom}
|
||||||
onToggleChromeCollapsed={toggleChromeCollapsed}
|
spellcheck={settings.spellcheck}
|
||||||
embedded
|
onToggleSpellcheck={toggleSpellcheck}
|
||||||
/>
|
showChromeToggle={Boolean(chrome)}
|
||||||
) : null
|
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">
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
<EditorContent editor={editor} className="h-full" />
|
<EditorContent editor={editor} className="h-full" />
|
||||||
@ -813,5 +979,6 @@ export function RichTextDocumentEditor({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</DocsParagraphStylesProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,9 +5,14 @@ import Link from "next/link"
|
|||||||
import { useQueryClient } from "@tanstack/react-query"
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
import { apiClient } from "@/lib/api/client"
|
import { apiClient } from "@/lib/api/client"
|
||||||
import { Button } from "@/components/ui/button"
|
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 { 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 { 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 { displayFileBaseName } from "@/lib/drive/display-file-name"
|
||||||
import { readDriveEditorReturnTo } from "@/lib/drive/drive-editor-return"
|
import { readDriveEditorReturnTo } from "@/lib/drive/drive-editor-return"
|
||||||
import { resolveRenameName } from "@/lib/drive/drive-default-name"
|
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 }) {
|
export function RichTextEditor({ fileId }: { fileId: string }) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const identity = useChromeIdentity()
|
const identity = useChromeIdentity()
|
||||||
|
const { ready } = useAuthReady()
|
||||||
const setSharePath = useDriveUIStore((s) => s.setSharePath)
|
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 displayPath = file?.path ?? ""
|
||||||
const [session, setSession] = useState<RichTextSessionResponse | null>(null)
|
const [session, setSession] = useState<RichTextSessionResponse | null>(null)
|
||||||
const [sessionError, setSessionError] = useState<string | 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 =
|
const error =
|
||||||
fileError instanceof Error
|
fileError instanceof Error
|
||||||
? fileError.message
|
? fileError.message
|
||||||
: sessionError
|
: sessionError
|
||||||
|
|
||||||
if (fileLoading) {
|
const errorView =
|
||||||
return (
|
error || !file ? (
|
||||||
<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 (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-8">
|
<div className="flex h-full flex-col items-center justify-center gap-4 p-8">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{error ?? "Document introuvable"}
|
{error ?? "Document introuvable"}
|
||||||
@ -180,24 +189,28 @@ export function RichTextEditor({ fileId }: { fileId: string }) {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
) : null
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh min-h-0 flex-col">
|
<DocsEditorLoadingShell
|
||||||
{!session ? (
|
title={title || undefined}
|
||||||
<div className="flex h-full items-center justify-center">
|
resolvingFile={resolvingFile}
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
awaitingSession={awaitingSession}
|
||||||
</div>
|
documentLoading={Boolean(session) && documentLoading}
|
||||||
) : (
|
documentPhase={documentPhase}
|
||||||
|
error={!resolvingFile ? errorView : null}
|
||||||
|
>
|
||||||
|
{session && file && !error ? (
|
||||||
<RichTextDocumentEditor
|
<RichTextDocumentEditor
|
||||||
session={session}
|
session={session}
|
||||||
mode="edit"
|
mode="edit"
|
||||||
userName={collabUserName}
|
userName={collabUserName}
|
||||||
userColor={collabUserColor}
|
userColor={collabUserColor}
|
||||||
chrome={chrome}
|
chrome={chrome}
|
||||||
|
deferSplash
|
||||||
|
onLoadingChange={onDocumentLoadingChange}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</DocsEditorLoadingShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
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 { apiClient } from "@/lib/api/client"
|
||||||
import type { DriveFileInfo } from "@/lib/api/types"
|
import type { DriveFileInfo } from "@/lib/api/types"
|
||||||
import { stashDriveEditorReturnTo } from "@/lib/drive/drive-editor-return"
|
import { stashDriveEditorReturnTo } from "@/lib/drive/drive-editor-return"
|
||||||
@ -53,9 +53,5 @@ export function RichTextEditorLegacyRedirect({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <DocsLoadingSplash phase="opening" />
|
||||||
<div className="flex h-dvh items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,10 +19,11 @@ export function CollabPresenceAvatars({
|
|||||||
users: CollabPresenceUser[]
|
users: CollabPresenceUser[]
|
||||||
className?: string
|
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 visible = remoteUsers.slice(0, MAX_VISIBLE)
|
||||||
const overflow = users.length - visible.length
|
const overflow = remoteUsers.length - visible.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
@ -31,19 +32,14 @@ export function CollabPresenceAvatars({
|
|||||||
<Tooltip key={user.clientId}>
|
<Tooltip key={user.clientId}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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]"
|
||||||
"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]"
|
|
||||||
)}
|
|
||||||
style={{ backgroundColor: user.color }}
|
style={{ backgroundColor: user.color }}
|
||||||
aria-label={user.isLocal ? `${user.name} (vous)` : user.name}
|
aria-label={user.name}
|
||||||
>
|
>
|
||||||
{senderInitial(user.name)}
|
{senderInitial(user.name)}
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">{user.name}</TooltipContent>
|
||||||
{user.isLocal ? `${user.name} (vous)` : user.name}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
{overflow > 0 ? (
|
{overflow > 0 ? (
|
||||||
@ -57,7 +53,7 @@ export function CollabPresenceAvatars({
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
{users.slice(MAX_VISIBLE).map((u) => u.name).join(", ")}
|
{remoteUsers.slice(MAX_VISIBLE).map((u) => u.name).join(", ")}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@ -11,6 +11,11 @@ import { DocsLogoIcon } from "@/components/drive/richtext/docs-logo-icon"
|
|||||||
import { DocsMenubar } from "@/components/drive/richtext/docs-menubar"
|
import { DocsMenubar } from "@/components/drive/richtext/docs-menubar"
|
||||||
import type { DocsEditMenuActions, DocsEditMenuState } from "@/components/drive/richtext/docs-edit-menu"
|
import type { DocsEditMenuActions, DocsEditMenuState } from "@/components/drive/richtext/docs-edit-menu"
|
||||||
import type { DocsFileMenuActions } from "@/components/drive/richtext/docs-file-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 type { DocsViewMenuActions, DocsViewMenuState } from "@/components/drive/richtext/docs-view-menu"
|
||||||
import { DocsMoveButton } from "@/components/drive/richtext/docs-move-button"
|
import { DocsMoveButton } from "@/components/drive/richtext/docs-move-button"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@ -67,6 +72,13 @@ export function DocsChrome({
|
|||||||
editMenuActions,
|
editMenuActions,
|
||||||
editMenuState,
|
editMenuState,
|
||||||
editMenuDisabled,
|
editMenuDisabled,
|
||||||
|
insertMenuActions,
|
||||||
|
insertMenuDialogs,
|
||||||
|
insertMenuDisabled,
|
||||||
|
insertMenuPageElementsEnabled,
|
||||||
|
formatMenuActions,
|
||||||
|
formatMenuState,
|
||||||
|
formatMenuDisabled,
|
||||||
renameSignal,
|
renameSignal,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
@ -94,6 +106,13 @@ export function DocsChrome({
|
|||||||
editMenuActions?: DocsEditMenuActions
|
editMenuActions?: DocsEditMenuActions
|
||||||
editMenuState?: DocsEditMenuState
|
editMenuState?: DocsEditMenuState
|
||||||
editMenuDisabled?: boolean
|
editMenuDisabled?: boolean
|
||||||
|
insertMenuActions?: DocsInsertMenuActions
|
||||||
|
insertMenuDialogs?: ReactNode
|
||||||
|
insertMenuDisabled?: boolean
|
||||||
|
insertMenuPageElementsEnabled?: boolean
|
||||||
|
formatMenuActions?: DocsFormatMenuActions
|
||||||
|
formatMenuState?: DocsFormatMenuState
|
||||||
|
formatMenuDisabled?: boolean
|
||||||
renameSignal?: number
|
renameSignal?: number
|
||||||
}) {
|
}) {
|
||||||
const shareIcon = resolveShareButtonIcon(shares)
|
const shareIcon = resolveShareButtonIcon(shares)
|
||||||
@ -107,7 +126,7 @@ export function DocsChrome({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="shrink-0 bg-white dark:bg-background">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@ -170,6 +189,12 @@ export function DocsChrome({
|
|||||||
editMenuActions={editMenuActions}
|
editMenuActions={editMenuActions}
|
||||||
editMenuState={editMenuState}
|
editMenuState={editMenuState}
|
||||||
editMenuDisabled={editMenuDisabled}
|
editMenuDisabled={editMenuDisabled}
|
||||||
|
insertMenuActions={insertMenuActions}
|
||||||
|
insertMenuDisabled={insertMenuDisabled}
|
||||||
|
insertMenuPageElementsEnabled={insertMenuPageElementsEnabled}
|
||||||
|
formatMenuActions={formatMenuActions}
|
||||||
|
formatMenuState={formatMenuState}
|
||||||
|
formatMenuDisabled={formatMenuDisabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -200,6 +225,7 @@ export function DocsChrome({
|
|||||||
</header>
|
</header>
|
||||||
{showShare ? <ShareDialog /> : null}
|
{showShare ? <ShareDialog /> : null}
|
||||||
{fileMenuDialogs}
|
{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 { useDocsRulerSync } from "@/lib/drive/use-docs-ruler-sync"
|
||||||
import { useDocsRulerMarginDrag } from "@/components/drive/richtext/use-docs-ruler-margin-drag"
|
import { useDocsRulerMarginDrag } from "@/components/drive/richtext/use-docs-ruler-margin-drag"
|
||||||
import { DocsRulerMarginDragTooltip } from "@/components/drive/richtext/docs-ruler-markers"
|
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"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export function DocsEditorWorkspace({
|
export function DocsEditorWorkspace({
|
||||||
@ -33,6 +44,7 @@ export function DocsEditorWorkspace({
|
|||||||
onRegionContentChange,
|
onRegionContentChange,
|
||||||
onPageSetupChange,
|
onPageSetupChange,
|
||||||
onRegionEditorChange,
|
onRegionEditorChange,
|
||||||
|
onPageStackReady,
|
||||||
}: {
|
}: {
|
||||||
editor: Editor
|
editor: Editor
|
||||||
pageLayout: DocPageLayout
|
pageLayout: DocPageLayout
|
||||||
@ -58,16 +70,41 @@ export function DocsEditorWorkspace({
|
|||||||
options?: { immediate?: boolean }
|
options?: { immediate?: boolean }
|
||||||
) => void
|
) => void
|
||||||
onRegionEditorChange?: (editor: import("@tiptap/react").Editor | null) => void
|
onRegionEditorChange?: (editor: import("@tiptap/react").Editor | null) => void
|
||||||
|
onPageStackReady?: (getPageStack: () => HTMLElement | null) => void
|
||||||
}) {
|
}) {
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const rulerTrackRef = useRef<HTMLDivElement>(null)
|
const rulerTrackRef = useRef<HTMLDivElement>(null)
|
||||||
const [pageCount, setPageCount] = useState(1)
|
const [pageCount, setPageCount] = useState(1)
|
||||||
const [narrowViewport, setNarrowViewport] = useState(false)
|
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 rulersVisible = showLayout && showRuler
|
||||||
const scale = docsZoomToScale(zoom)
|
const scale = docsZoomToScale(zoom)
|
||||||
const showToolbarShell = Boolean(toolbar) || rulersVisible
|
const showToolbarShell = Boolean(toolbar) || rulersVisible
|
||||||
const marginsEditable = editable && editorMode !== "view"
|
const marginsEditable = editable && editorMode !== "view"
|
||||||
|
const graphicOptionsSidebarOpen =
|
||||||
|
graphicOptionsOpen && editable && editorMode !== "view"
|
||||||
|
const graphicOptionsSidebarInset = graphicOptionsSidebarOpen
|
||||||
|
? DOCS_GRAPHIC_OPTIONS_SIDEBAR_WIDTH_PX
|
||||||
|
: 0
|
||||||
|
|
||||||
const {
|
const {
|
||||||
pageLayoutWithMargins,
|
pageLayoutWithMargins,
|
||||||
@ -95,9 +132,30 @@ export function DocsEditorWorkspace({
|
|||||||
onCurrentPageChange?.(rulerSync.currentPage + 1)
|
onCurrentPageChange?.(rulerSync.currentPage + 1)
|
||||||
}, [onCurrentPageChange, rulerSync.currentPage])
|
}, [onCurrentPageChange, rulerSync.currentPage])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onPageStackReady?.(
|
||||||
|
() => canvasRef.current?.querySelector<HTMLElement>("[data-docs-page-stack]") ?? null
|
||||||
|
)
|
||||||
|
}, [onPageStackReady, pageCount, showLayout, zoom])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="docs-editor-workspace flex min-h-0 flex-1 flex-col">
|
<div className="docs-editor-workspace flex min-h-0 flex-1 flex-col">
|
||||||
<DocsRulerMarginDragTooltip tooltip={dragTooltip} />
|
<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 ? (
|
{showToolbarShell ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -112,6 +170,7 @@ export function DocsEditorWorkspace({
|
|||||||
scale={scale}
|
scale={scale}
|
||||||
rulerSync={rulerSync}
|
rulerSync={rulerSync}
|
||||||
rulerTrackRef={rulerTrackRef}
|
rulerTrackRef={rulerTrackRef}
|
||||||
|
contentInsetRight={graphicOptionsSidebarInset}
|
||||||
outlineExpanded={outlineExpanded}
|
outlineExpanded={outlineExpanded}
|
||||||
onToggleOutline={onToggleOutline}
|
onToggleOutline={onToggleOutline}
|
||||||
editable={marginsEditable}
|
editable={marginsEditable}
|
||||||
@ -137,31 +196,43 @@ export function DocsEditorWorkspace({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex h-full min-h-0 flex-col"
|
className="flex h-full min-h-0 flex-row"
|
||||||
style={
|
style={
|
||||||
rulersVisible
|
rulersVisible
|
||||||
? { paddingLeft: DOCS_VERTICAL_RULER_WIDTH_PX }
|
? { paddingLeft: DOCS_VERTICAL_RULER_WIDTH_PX }
|
||||||
: undefined
|
: 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}
|
editor={editor}
|
||||||
pageLayout={pageLayoutWithMargins}
|
pageLayout={pageLayoutWithMargins}
|
||||||
zoom={zoom}
|
open={graphicOptionsSidebarOpen}
|
||||||
editable={editable}
|
focusSection={graphicOptionsSection}
|
||||||
showLayout={showLayout}
|
onClose={() => {
|
||||||
showRuler={false}
|
setGraphicOptionsOpen(false)
|
||||||
showNonPrintableChars={showNonPrintableChars}
|
setGraphicOptionsSection(null)
|
||||||
editorMode={editorMode}
|
|
||||||
canvasRef={canvasRef}
|
|
||||||
onPageCountChange={(count) => {
|
|
||||||
setPageCount(count)
|
|
||||||
onPageCountChange?.(count)
|
|
||||||
}}
|
}}
|
||||||
onNarrowViewportChange={setNarrowViewport}
|
|
||||||
onRegionContentChange={onRegionContentChange}
|
|
||||||
onPageSetupChange={onPageSetupChange}
|
|
||||||
onRegionEditorChange={onRegionEditorChange}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
"use client"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Children,
|
||||||
|
cloneElement,
|
||||||
createContext,
|
createContext,
|
||||||
|
isValidElement,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useId,
|
useId,
|
||||||
useState,
|
useState,
|
||||||
|
type ReactElement,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react"
|
} from "react"
|
||||||
import { MenubarSub } from "@/components/ui/menubar"
|
import { MenubarSub, MenubarSubContent } from "@/components/ui/menubar"
|
||||||
|
|
||||||
type ExclusiveMenuSubContextValue = {
|
type ExclusiveMenuSubContextValue = {
|
||||||
openId: string | null
|
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({
|
export function DocsExclusiveMenuSub({
|
||||||
id,
|
id,
|
||||||
children,
|
children,
|
||||||
@ -42,7 +61,14 @@ export function DocsExclusiveMenuSub({
|
|||||||
const onOpenChange = useCallback(
|
const onOpenChange = useCallback(
|
||||||
(next: boolean) => {
|
(next: boolean) => {
|
||||||
if (!ctx) return
|
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]
|
[ctx, subId]
|
||||||
)
|
)
|
||||||
@ -53,7 +79,7 @@ export function DocsExclusiveMenuSub({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MenubarSub open={open} onOpenChange={onOpenChange}>
|
<MenubarSub open={open} onOpenChange={onOpenChange}>
|
||||||
{children}
|
{withNestedExclusiveSubRoot(children)}
|
||||||
</MenubarSub>
|
</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,
|
ContextMenuSubTrigger,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@/components/ui/context-menu"
|
} from "@/components/ui/context-menu"
|
||||||
|
import { openDocsGraphicDrawEditor } from "@/lib/drive/docs-graphic-draw-bridge"
|
||||||
import {
|
import {
|
||||||
DOCS_GRAPHIC_WRAP_LABELS,
|
DOCS_GRAPHIC_WRAP_LABELS,
|
||||||
parseGraphicAttrs,
|
parseGraphicAttrs,
|
||||||
@ -39,7 +40,7 @@ export function DocsGraphicContextMenu({
|
|||||||
const isImage = attrs.graphicType === "image"
|
const isImage = attrs.graphicType === "image"
|
||||||
|
|
||||||
const applyWrap = (wrap: DocsGraphicWrap) => {
|
const applyWrap = (wrap: DocsGraphicWrap) => {
|
||||||
editor.chain().focus().setDocsGraphicWrap(wrap).run()
|
editor.chain().setDocsGraphicWrap(wrap).run()
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadImage = () => {
|
const downloadImage = () => {
|
||||||
@ -53,7 +54,10 @@ export function DocsGraphicContextMenu({
|
|||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||||
<ContextMenuContent className="min-w-52">
|
<ContextMenuContent
|
||||||
|
className="min-w-52"
|
||||||
|
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={() => document.execCommand("cut")}
|
onClick={() => document.execCommand("cut")}
|
||||||
>
|
>
|
||||||
@ -92,6 +96,10 @@ export function DocsGraphicContextMenu({
|
|||||||
Télécharger l'image
|
Télécharger l'image
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
</>
|
</>
|
||||||
|
) : attrs.graphicType === "draw" ? (
|
||||||
|
<ContextMenuItem onClick={() => openDocsGraphicDrawEditor(editor)}>
|
||||||
|
Modifier le dessin…
|
||||||
|
</ContextMenuItem>
|
||||||
) : attrs.graphicType === "shape" ? (
|
) : attrs.graphicType === "shape" ? (
|
||||||
<>
|
<>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
@ -115,7 +123,7 @@ export function DocsGraphicContextMenu({
|
|||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuSub>
|
<ContextMenuSub>
|
||||||
<ContextMenuSubTrigger>Habillage texte</ContextMenuSubTrigger>
|
<ContextMenuSubTrigger>Habillage texte</ContextMenuSubTrigger>
|
||||||
<ContextMenuSubContent>
|
<ContextMenuSubContent onCloseAutoFocus={(event) => event.preventDefault()}>
|
||||||
{(Object.keys(DOCS_GRAPHIC_WRAP_LABELS) as DocsGraphicWrap[]).map((wrap) => (
|
{(Object.keys(DOCS_GRAPHIC_WRAP_LABELS) as DocsGraphicWrap[]).map((wrap) => (
|
||||||
<ContextMenuItem key={wrap} onClick={() => applyWrap(wrap)}>
|
<ContextMenuItem key={wrap} onClick={() => applyWrap(wrap)}>
|
||||||
{DOCS_GRAPHIC_WRAP_LABELS[wrap]}
|
{DOCS_GRAPHIC_WRAP_LABELS[wrap]}
|
||||||
|
|||||||
@ -1,70 +1,74 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef } from "react"
|
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"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"] as const
|
const HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"] as const
|
||||||
type Handle = (typeof HANDLES)[number]
|
type Handle = (typeof HANDLES)[number]
|
||||||
|
|
||||||
const HANDLE_CLASS: Record<Handle, string> = {
|
const HANDLE_CLASS: Record<Handle, string> = {
|
||||||
nw: "left-0 top-0 cursor-nwse-resize",
|
nw: "docs-crop-handle--corner docs-crop-handle--nw cursor-nwse-resize",
|
||||||
n: "left-1/2 top-0 -translate-x-1/2 cursor-ns-resize",
|
n: "docs-crop-handle--edge docs-crop-handle--n cursor-ns-resize",
|
||||||
ne: "right-0 top-0 cursor-nesw-resize",
|
ne: "docs-crop-handle--corner docs-crop-handle--ne cursor-nesw-resize",
|
||||||
e: "right-0 top-1/2 -translate-y-1/2 cursor-ew-resize",
|
e: "docs-crop-handle--edge docs-crop-handle--e cursor-ew-resize",
|
||||||
se: "right-0 bottom-0 cursor-nwse-resize",
|
se: "docs-crop-handle--corner docs-crop-handle--se cursor-nwse-resize",
|
||||||
s: "left-1/2 bottom-0 -translate-x-1/2 cursor-ns-resize",
|
s: "docs-crop-handle--edge docs-crop-handle--s cursor-ns-resize",
|
||||||
sw: "left-0 bottom-0 cursor-nesw-resize",
|
sw: "docs-crop-handle--corner docs-crop-handle--sw cursor-nesw-resize",
|
||||||
w: "left-0 top-1/2 -translate-y-1/2 cursor-ew-resize",
|
w: "docs-crop-handle--edge docs-crop-handle--w cursor-ew-resize",
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp01(v: number): number {
|
type CropRegion = Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">
|
||||||
return Math.min(1, Math.max(0, v))
|
|
||||||
}
|
|
||||||
|
|
||||||
function resizeCrop(
|
function pct(value: number, total: number): string {
|
||||||
handle: Handle,
|
return `${(value / Math.max(total, 1)) * 100}%`
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocsGraphicCropOverlay({
|
export function DocsGraphicCropOverlay({
|
||||||
attrs,
|
attrs,
|
||||||
|
cropEditBase,
|
||||||
frameWidth,
|
frameWidth,
|
||||||
frameHeight,
|
frameHeight,
|
||||||
|
imageNaturalWidth,
|
||||||
|
imageNaturalHeight,
|
||||||
onChange,
|
onChange,
|
||||||
onDone,
|
onDone,
|
||||||
}: {
|
}: {
|
||||||
attrs: DocsGraphicAttrs
|
attrs: DocsGraphicAttrs
|
||||||
|
cropEditBase?: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight"> | null
|
||||||
frameWidth: number
|
frameWidth: number
|
||||||
frameHeight: number
|
frameHeight: number
|
||||||
|
imageNaturalWidth: number
|
||||||
|
imageNaturalHeight: number
|
||||||
onChange: (patch: Partial<DocsGraphicAttrs>) => void
|
onChange: (patch: Partial<DocsGraphicAttrs>) => void
|
||||||
onDone: () => void
|
onDone: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { windowRect, cropRect } = computeCropDisplayGeometry(
|
||||||
|
attrs,
|
||||||
|
frameWidth,
|
||||||
|
frameHeight,
|
||||||
|
imageNaturalWidth,
|
||||||
|
imageNaturalHeight,
|
||||||
|
cropEditBase
|
||||||
|
)
|
||||||
const dragRef = useRef<{
|
const dragRef = useRef<{
|
||||||
handle: Handle
|
handle: Handle
|
||||||
startX: number
|
startX: number
|
||||||
startY: number
|
startY: number
|
||||||
origin: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">
|
origin: CropRegion
|
||||||
} | null>(null)
|
} | 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) => {
|
(handle: Handle, event: React.PointerEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@ -88,53 +92,118 @@ export function DocsGraphicCropOverlay({
|
|||||||
const onMove = (event: PointerEvent) => {
|
const onMove = (event: PointerEvent) => {
|
||||||
if (!dragRef.current) return
|
if (!dragRef.current) return
|
||||||
const { handle, startX, startY, origin } = dragRef.current
|
const { handle, startX, startY, origin } = dragRef.current
|
||||||
const dxNorm = (event.clientX - startX) / Math.max(frameWidth, 1)
|
const scale = readScale()
|
||||||
const dyNorm = (event.clientY - startY) / Math.max(frameHeight, 1)
|
const dxPx = (event.clientX - startX) / scale
|
||||||
onChange(resizeCrop(handle, origin, dxNorm, dyNorm))
|
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 = () => {
|
const onUp = () => {
|
||||||
dragRef.current = null
|
dragRef.current = null
|
||||||
}
|
}
|
||||||
window.addEventListener("pointermove", onMove)
|
window.addEventListener("pointermove", onMove)
|
||||||
window.addEventListener("pointerup", onUp)
|
window.addEventListener("pointerup", onUp)
|
||||||
|
window.addEventListener("pointercancel", onUp)
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("pointermove", onMove)
|
window.removeEventListener("pointermove", onMove)
|
||||||
window.removeEventListener("pointerup", onUp)
|
window.removeEventListener("pointerup", onUp)
|
||||||
|
window.removeEventListener("pointercancel", onUp)
|
||||||
}
|
}
|
||||||
}, [frameHeight, frameWidth, onChange])
|
}, [onChange, readScale, windowRect.height, windowRect.width])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (event: KeyboardEvent) => {
|
const onKey = (event: KeyboardEvent) => {
|
||||||
if (event.key === "Escape") onDone()
|
if (event.key === "Escape" || event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener("keydown", onKey)
|
window.addEventListener("keydown", onKey, true)
|
||||||
return () => window.removeEventListener("keydown", onKey)
|
return () => window.removeEventListener("keydown", onKey, true)
|
||||||
}, [onDone])
|
}, [onDone])
|
||||||
|
|
||||||
const left = `${attrs.cropX * 100}%`
|
const localCropLeft = cropRect.left - windowRect.left
|
||||||
const top = `${attrs.cropY * 100}%`
|
const localCropTop = cropRect.top - windowRect.top
|
||||||
const width = `${attrs.cropWidth * 100}%`
|
const cropWidth = pct(cropRect.width, windowRect.width)
|
||||||
const height = `${attrs.cropHeight * 100}%`
|
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 (
|
return (
|
||||||
<div className="docs-graphic-crop absolute inset-0 z-30" aria-label="Recadrage">
|
<div
|
||||||
<div className="absolute inset-0 bg-black/40" />
|
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
|
<div
|
||||||
className={cn(
|
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"
|
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) => (
|
{HANDLES.map((handle) => (
|
||||||
<span
|
<span
|
||||||
key={handle}
|
key={handle}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
className={cn(
|
className={cn("docs-crop-handle pointer-events-auto absolute z-40", HANDLE_CLASS[handle])}
|
||||||
"absolute z-40 size-2.5 rounded-full border border-white bg-[#1a73e8] shadow",
|
onPointerDown={(event) => beginResize(handle, event)}
|
||||||
HANDLE_CLASS[handle]
|
|
||||||
)}
|
|
||||||
onPointerDown={(event) => onHandleDown(handle, event)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<p className="text-sm font-medium">
|
||||||
{attrs.graphicType === "image"
|
{attrs.graphicType === "image"
|
||||||
? "Options image"
|
? "Options image"
|
||||||
: attrs.graphicType === "shape"
|
: attrs.graphicType === "draw"
|
||||||
? "Options forme"
|
? "Options dessin"
|
||||||
: "Options dégradé"}
|
: attrs.graphicType === "shape"
|
||||||
|
? "Options forme"
|
||||||
|
: "Options dégradé"}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@ -174,6 +176,41 @@ export function DocsGraphicOptionsPanel({
|
|||||||
|
|
||||||
{attrs.graphicType === "gradient" ? (
|
{attrs.graphicType === "gradient" ? (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<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">
|
<div className="grid gap-1">
|
||||||
<Label className="text-xs text-muted-foreground">Couleur 1</Label>
|
<Label className="text-xs text-muted-foreground">Couleur 1</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -188,7 +225,8 @@ export function DocsGraphicOptionsPanel({
|
|||||||
gradientCss: buildGradientCss(
|
gradientCss: buildGradientCss(
|
||||||
attrs.gradientAngle,
|
attrs.gradientAngle,
|
||||||
gradientColor1,
|
gradientColor1,
|
||||||
attrs.gradientColor2
|
attrs.gradientColor2,
|
||||||
|
attrs.gradientType
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
@ -208,32 +246,36 @@ export function DocsGraphicOptionsPanel({
|
|||||||
gradientCss: buildGradientCss(
|
gradientCss: buildGradientCss(
|
||||||
attrs.gradientAngle,
|
attrs.gradientAngle,
|
||||||
attrs.gradientColor1,
|
attrs.gradientColor1,
|
||||||
gradientColor2
|
gradientColor2,
|
||||||
|
attrs.gradientType
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 grid gap-1">
|
{attrs.gradientType === "linear" ? (
|
||||||
<Label className="text-xs text-muted-foreground">Angle (°)</Label>
|
<div className="col-span-2 grid gap-1">
|
||||||
<Input
|
<Label className="text-xs text-muted-foreground">Angle (°)</Label>
|
||||||
type="number"
|
<Input
|
||||||
className="h-8"
|
type="number"
|
||||||
disabled={disabled}
|
className="h-8"
|
||||||
value={attrs.gradientAngle}
|
disabled={disabled}
|
||||||
onChange={(e) => {
|
value={attrs.gradientAngle}
|
||||||
const gradientAngle = Number(e.target.value) || 0
|
onChange={(e) => {
|
||||||
update({
|
const gradientAngle = Number(e.target.value) || 0
|
||||||
gradientAngle,
|
update({
|
||||||
gradientCss: buildGradientCss(
|
|
||||||
gradientAngle,
|
gradientAngle,
|
||||||
attrs.gradientColor1,
|
gradientCss: buildGradientCss(
|
||||||
attrs.gradientColor2
|
gradientAngle,
|
||||||
),
|
attrs.gradientColor1,
|
||||||
})
|
attrs.gradientColor2,
|
||||||
}}
|
attrs.gradientType
|
||||||
/>
|
),
|
||||||
</div>
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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"
|
"use client"
|
||||||
|
|
||||||
import { useRef } from "react"
|
import { useRef, useState } from "react"
|
||||||
import type { Editor } from "@tiptap/react"
|
import type { Editor } from "@tiptap/react"
|
||||||
import { Icon } from "@iconify/react"
|
import { Icon } from "@iconify/react"
|
||||||
|
import { ImageIcon, Pencil, Sparkles } from "lucide-react"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@ -14,7 +15,12 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Button } from "@/components/ui/button"
|
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 {
|
import {
|
||||||
DOCS_GRAPHIC_PLACEMENT_LABELS,
|
DOCS_GRAPHIC_PLACEMENT_LABELS,
|
||||||
DOCS_GRAPHIC_WRAP_LABELS,
|
DOCS_GRAPHIC_WRAP_LABELS,
|
||||||
@ -23,6 +29,7 @@ import {
|
|||||||
type DocsGraphicWrap,
|
type DocsGraphicWrap,
|
||||||
parseGraphicAttrs,
|
parseGraphicAttrs,
|
||||||
} from "@/lib/drive/docs-graphic-types"
|
} from "@/lib/drive/docs-graphic-types"
|
||||||
|
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
|
||||||
|
|
||||||
function readSelectedGraphicAttrs(editor: Editor) {
|
function readSelectedGraphicAttrs(editor: Editor) {
|
||||||
if (editor.isActive("docsGraphic")) {
|
if (editor.isActive("docsGraphic")) {
|
||||||
@ -42,26 +49,32 @@ export function DocsGraphicInsertMenu({
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [drivePickerOpen, setDrivePickerOpen] = useState(false)
|
||||||
|
|
||||||
if (!editor) return null
|
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()
|
const reader = new FileReader()
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
const src = reader.result as string
|
const src = reader.result as string
|
||||||
editor
|
insertImageSrc(src, options)
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.insertDocsGraphic(
|
|
||||||
buildInsertGraphicAttrs("image", {
|
|
||||||
src,
|
|
||||||
wrap: options?.wrap ?? "square",
|
|
||||||
placement: options?.placement ?? "block",
|
|
||||||
width: 280,
|
|
||||||
height: 180,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.run()
|
|
||||||
}
|
}
|
||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
}
|
}
|
||||||
@ -81,35 +94,35 @@ export function DocsGraphicInsertMenu({
|
|||||||
<Icon icon="material-symbols:image-outline" className="size-4" />
|
<Icon icon="material-symbols:image-outline" className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="min-w-56">
|
<DropdownMenuContent align="start" className="min-w-64">
|
||||||
<DropdownMenuItem onClick={() => imageInputRef.current?.click()}>
|
<DropdownMenuItem onClick={() => setDrivePickerOpen(true)}>
|
||||||
Image…
|
<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>
|
</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
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
editor
|
editor
|
||||||
@ -119,10 +132,20 @@ export function DocsGraphicInsertMenu({
|
|||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<Sparkles className="size-4 shrink-0" />
|
||||||
Dégradé
|
Dégradé
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<DocsDriveImagePickerDialog
|
||||||
|
open={drivePickerOpen}
|
||||||
|
onOpenChange={setDrivePickerOpen}
|
||||||
|
onPickImage={(src, file) => {
|
||||||
|
insertImageSrc(src, { alt: file.name })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
ref={imageInputRef}
|
ref={imageInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@ -130,7 +153,7 @@ export function DocsGraphicInsertMenu({
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0]
|
||||||
if (file) insertImage(file)
|
if (file) insertImageFile(file)
|
||||||
event.target.value = ""
|
event.target.value = ""
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -150,7 +173,7 @@ export function DocsGraphicLayoutMenu({
|
|||||||
if (!attrs) return null
|
if (!attrs) return null
|
||||||
|
|
||||||
const applyWrap = (wrap: DocsGraphicWrap) => {
|
const applyWrap = (wrap: DocsGraphicWrap) => {
|
||||||
editor.chain().focus().setDocsGraphicWrap(wrap).run()
|
editor.chain().setDocsGraphicWrap(wrap).run()
|
||||||
}
|
}
|
||||||
const applyPlacement = (placement: DocsGraphicPlacement) => {
|
const applyPlacement = (placement: DocsGraphicPlacement) => {
|
||||||
editor.chain().focus().setDocsGraphicPlacement(placement).run()
|
editor.chain().focus().setDocsGraphicPlacement(placement).run()
|
||||||
@ -173,10 +196,14 @@ export function DocsGraphicLayoutMenu({
|
|||||||
<Icon icon="material-symbols:layers-outline" className="size-4" />
|
<Icon icon="material-symbols:layers-outline" className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="min-w-64">
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
className="min-w-64"
|
||||||
|
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger>Habillage texte</DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>Habillage texte</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent>
|
<DropdownMenuSubContent onCloseAutoFocus={(event) => event.preventDefault()}>
|
||||||
{(Object.keys(DOCS_GRAPHIC_WRAP_LABELS) as DocsGraphicWrap[]).map((wrap) => (
|
{(Object.keys(DOCS_GRAPHIC_WRAP_LABELS) as DocsGraphicWrap[]).map((wrap) => (
|
||||||
<DropdownMenuItem key={wrap} onClick={() => applyWrap(wrap)}>
|
<DropdownMenuItem key={wrap} onClick={() => applyWrap(wrap)}>
|
||||||
{DOCS_GRAPHIC_WRAP_LABELS[wrap]}
|
{DOCS_GRAPHIC_WRAP_LABELS[wrap]}
|
||||||
|
|||||||
@ -84,7 +84,7 @@ function DocsHorizontalRulerInner({
|
|||||||
{ticks.map((tick, index) => (
|
{ticks.map((tick, index) => (
|
||||||
<div
|
<div
|
||||||
key={`${tick.pos}-${index}`}
|
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={{
|
style={{
|
||||||
left: s(tick.pos),
|
left: s(tick.pos),
|
||||||
height: tick.major ? 10 : 5,
|
height: tick.major ? 10 : 5,
|
||||||
@ -97,7 +97,7 @@ function DocsHorizontalRulerInner({
|
|||||||
.map((tick) => (
|
.map((tick) => (
|
||||||
<span
|
<span
|
||||||
key={`label-${tick.pos}`}
|
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) }}
|
style={{ left: s(tick.pos) }}
|
||||||
>
|
>
|
||||||
{tick.label}
|
{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 { DocsEditMenu, type DocsEditMenuActions, type DocsEditMenuState } from "@/components/drive/richtext/docs-edit-menu"
|
||||||
import { DocsFileMenu, type DocsFileMenuActions } from "@/components/drive/richtext/docs-file-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 { 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 { DOCS_MENUBAR_CONTENT_PROPS } from "@/components/drive/richtext/docs-menubar-props"
|
||||||
import {
|
import {
|
||||||
@ -13,7 +19,7 @@ import {
|
|||||||
} from "@/components/ui/menubar"
|
} from "@/components/ui/menubar"
|
||||||
import { cn } from "@/lib/utils"
|
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({
|
export function DocsMenubar({
|
||||||
viewMenuActions,
|
viewMenuActions,
|
||||||
@ -24,6 +30,12 @@ export function DocsMenubar({
|
|||||||
editMenuActions,
|
editMenuActions,
|
||||||
editMenuState,
|
editMenuState,
|
||||||
editMenuDisabled,
|
editMenuDisabled,
|
||||||
|
insertMenuActions,
|
||||||
|
insertMenuDisabled,
|
||||||
|
insertMenuPageElementsEnabled,
|
||||||
|
formatMenuActions,
|
||||||
|
formatMenuState,
|
||||||
|
formatMenuDisabled,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
viewMenuActions?: DocsViewMenuActions
|
viewMenuActions?: DocsViewMenuActions
|
||||||
@ -34,6 +46,12 @@ export function DocsMenubar({
|
|||||||
editMenuActions?: DocsEditMenuActions
|
editMenuActions?: DocsEditMenuActions
|
||||||
editMenuState?: DocsEditMenuState
|
editMenuState?: DocsEditMenuState
|
||||||
editMenuDisabled?: boolean
|
editMenuDisabled?: boolean
|
||||||
|
insertMenuActions?: DocsInsertMenuActions
|
||||||
|
insertMenuDisabled?: boolean
|
||||||
|
insertMenuPageElementsEnabled?: boolean
|
||||||
|
formatMenuActions?: DocsFormatMenuActions
|
||||||
|
formatMenuState?: DocsFormatMenuState
|
||||||
|
formatMenuDisabled?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -90,6 +108,44 @@ export function DocsMenubar({
|
|||||||
</MenubarMenu>
|
</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) => (
|
{OTHER_MENU_LABELS.map((label) => (
|
||||||
<MenubarMenu key={label}>
|
<MenubarMenu key={label}>
|
||||||
<MenubarTrigger className="docs-menu-trigger">{label}</MenubarTrigger>
|
<MenubarTrigger className="docs-menu-trigger">{label}</MenubarTrigger>
|
||||||
|
|||||||
@ -9,6 +9,10 @@ import {
|
|||||||
type DocsHeaderFooterRegion,
|
type DocsHeaderFooterRegion,
|
||||||
} from "@/components/drive/richtext/docs-header-footer-region"
|
} from "@/components/drive/richtext/docs-header-footer-region"
|
||||||
import { DocsBodyMarginMasks } from "@/components/drive/richtext/docs-body-margin-masks"
|
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 {
|
import {
|
||||||
DOCS_CANVAS_PADDING_TOP_NARROW_PX,
|
DOCS_CANVAS_PADDING_TOP_NARROW_PX,
|
||||||
DOCS_CANVAS_PADDING_Y_PX,
|
DOCS_CANVAS_PADDING_Y_PX,
|
||||||
@ -25,8 +29,10 @@ import {
|
|||||||
} from "@/lib/drive/docs-page-metrics"
|
} from "@/lib/drive/docs-page-metrics"
|
||||||
import { docsPageLengthToScreen, docsZoomToScale } from "@/lib/drive/docs-ruler-scale"
|
import { docsPageLengthToScreen, docsZoomToScale } from "@/lib/drive/docs-ruler-scale"
|
||||||
import { cn } from "@/lib/utils"
|
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 { 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). */
|
/** Total layout height inside ProseMirror (blocks + flow spacers). */
|
||||||
function measureProseContentHeight(prose: HTMLElement): number {
|
function measureProseContentHeight(prose: HTMLElement): number {
|
||||||
@ -187,6 +193,16 @@ function DocsPageViewInner({
|
|||||||
return () => window.removeEventListener("keydown", onKey)
|
return () => window.removeEventListener("keydown", onKey)
|
||||||
}, [editingTarget, stopRegionEdit])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
if (!canvas) return
|
if (!canvas) return
|
||||||
@ -351,12 +367,19 @@ function DocsPageViewInner({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="relative mx-auto overflow-hidden"
|
className="relative mx-auto overflow-visible"
|
||||||
style={{ width: scaledWidth, height: showLayout ? scaledHeight : undefined }}
|
style={{ width: scaledWidth, height: showLayout ? scaledHeight : undefined }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-docs-page-stack
|
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={{
|
style={{
|
||||||
width: pageWidth,
|
width: pageWidth,
|
||||||
height: stackHeight,
|
height: stackHeight,
|
||||||
@ -495,6 +518,20 @@ function DocsPageViewInner({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : 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
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -532,10 +569,14 @@ function DocsPageViewInner({
|
|||||||
focusEditorAtPointer(editor, event.clientX, event.clientY)
|
focusEditorAtPointer(editor, event.clientX, event.clientY)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EditorContent
|
<DocsTableContextMenu editor={editor} disabled={!editable || bodyDimmed}>
|
||||||
editor={editor}
|
<div className="min-h-0 min-w-0">
|
||||||
className={cn(!editable && "pointer-events-none select-text")}
|
<EditorContent
|
||||||
/>
|
editor={editor}
|
||||||
|
className={cn(!editable && "pointer-events-none select-text")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DocsTableContextMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -562,7 +603,7 @@ export function DocsStatusBar({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
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,
|
scale,
|
||||||
rulerSync,
|
rulerSync,
|
||||||
rulerTrackRef,
|
rulerTrackRef,
|
||||||
|
contentInsetRight = 0,
|
||||||
outlineExpanded,
|
outlineExpanded,
|
||||||
onToggleOutline,
|
onToggleOutline,
|
||||||
editable,
|
editable,
|
||||||
@ -30,6 +31,8 @@ export function DocsRulerToolbarRow({
|
|||||||
scale: number
|
scale: number
|
||||||
rulerSync: DocsRulerSyncState
|
rulerSync: DocsRulerSyncState
|
||||||
rulerTrackRef: RefObject<HTMLDivElement | null>
|
rulerTrackRef: RefObject<HTMLDivElement | null>
|
||||||
|
/** Right gutter matching panels beside the canvas (e.g. graphic options sidebar). */
|
||||||
|
contentInsetRight?: number
|
||||||
outlineExpanded?: boolean
|
outlineExpanded?: boolean
|
||||||
onToggleOutline?: () => void
|
onToggleOutline?: () => void
|
||||||
editable?: boolean
|
editable?: boolean
|
||||||
@ -70,7 +73,7 @@ export function DocsRulerToolbarRow({
|
|||||||
className="relative min-w-0 flex-1 overflow-visible"
|
className="relative min-w-0 flex-1 overflow-visible"
|
||||||
style={{
|
style={{
|
||||||
height: DOCS_HORIZONTAL_RULER_HEIGHT_PX,
|
height: DOCS_HORIZONTAL_RULER_HEIGHT_PX,
|
||||||
paddingRight: rulerSync.canvasScrollbarWidth,
|
paddingRight: rulerSync.canvasScrollbarWidth + contentInsetRight,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<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,
|
ChevronUp,
|
||||||
Bold,
|
Bold,
|
||||||
Italic,
|
Italic,
|
||||||
Link2,
|
|
||||||
List,
|
List,
|
||||||
ListOrdered,
|
ListOrdered,
|
||||||
Minus,
|
Minus,
|
||||||
@ -50,26 +49,22 @@ import {
|
|||||||
stepFontSizePx,
|
stepFontSizePx,
|
||||||
} from "@/lib/drive/docs-font-size"
|
} from "@/lib/drive/docs-font-size"
|
||||||
import {
|
import {
|
||||||
applyFontFamily,
|
|
||||||
DOCS_FONT_FAMILIES,
|
DOCS_FONT_FAMILIES,
|
||||||
type DocsFontFamilyName,
|
|
||||||
} from "@/lib/drive/docs-font-family"
|
} from "@/lib/drive/docs-font-family"
|
||||||
import {
|
import {
|
||||||
DocsGraphicInsertMenu,
|
DocsGraphicInsertMenu,
|
||||||
DocsGraphicLayoutMenu,
|
|
||||||
readGraphicToolbarActive,
|
|
||||||
} from "@/components/drive/richtext/docs-graphic-toolbar-menu"
|
} 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 { 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"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const TEXT_STYLES = [
|
const ZOOM_OPTIONS = [50, 75, 90, 100, 125, 150, 200] as const
|
||||||
{ 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 TEXT_COLORS = [
|
const TEXT_COLORS = [
|
||||||
"#000000",
|
"#000000",
|
||||||
@ -109,17 +104,6 @@ const HIGHLIGHT_COLORS = [
|
|||||||
"#e6e6e6",
|
"#e6e6e6",
|
||||||
] as const
|
] 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({
|
function DocsToolbarInner({
|
||||||
editor,
|
editor,
|
||||||
disabled,
|
disabled,
|
||||||
@ -130,6 +114,7 @@ function DocsToolbarInner({
|
|||||||
showChromeToggle,
|
showChromeToggle,
|
||||||
chromeCollapsed,
|
chromeCollapsed,
|
||||||
onToggleChromeCollapsed,
|
onToggleChromeCollapsed,
|
||||||
|
onPrint,
|
||||||
embedded,
|
embedded,
|
||||||
}: {
|
}: {
|
||||||
editor: Editor | null
|
editor: Editor | null
|
||||||
@ -141,25 +126,15 @@ function DocsToolbarInner({
|
|||||||
showChromeToggle?: boolean
|
showChromeToggle?: boolean
|
||||||
chromeCollapsed?: boolean
|
chromeCollapsed?: boolean
|
||||||
onToggleChromeCollapsed?: () => void
|
onToggleChromeCollapsed?: () => void
|
||||||
|
onPrint?: () => void
|
||||||
/** Rendered inside DocsEditorWorkspace shell (no outer docs-toolbar-shell). */
|
/** Rendered inside DocsEditorWorkspace shell (no outer docs-toolbar-shell). */
|
||||||
embedded?: boolean
|
embedded?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [linkOpen, setLinkOpen] = useState(false)
|
|
||||||
const [linkUrl, setLinkUrl] = useState("")
|
|
||||||
const toolbarState = useDocsToolbarState(editor)
|
const toolbarState = useDocsToolbarState(editor)
|
||||||
const graphicSelected = readGraphicToolbarActive(editor)
|
const paragraphStylesCtx = useDocsParagraphStylesContext()
|
||||||
|
const fontsQuery = useDocsFonts()
|
||||||
const applyLink = useCallback(() => {
|
const fonts = fontsQuery.data ?? DOCS_FONT_FAMILIES.map((f) => ({ name: f.name, stack: f.stack }))
|
||||||
if (!editor) return
|
const [customSpacingOpen, setCustomSpacingOpen] = useState(false)
|
||||||
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 segments = useMemo(() => {
|
const segments = useMemo(() => {
|
||||||
if (!editor || !toolbarState) return []
|
if (!editor || !toolbarState) return []
|
||||||
@ -184,6 +159,10 @@ function DocsToolbarInner({
|
|||||||
alignJustify,
|
alignJustify,
|
||||||
isBulletList,
|
isBulletList,
|
||||||
isOrderedList,
|
isOrderedList,
|
||||||
|
isTaskList,
|
||||||
|
canIncreaseIndent,
|
||||||
|
canDecreaseIndent,
|
||||||
|
lineHeightPresetId,
|
||||||
} = toolbarState
|
} = toolbarState
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -214,7 +193,7 @@ function DocsToolbarInner({
|
|||||||
sepAfter: false,
|
sepAfter: false,
|
||||||
node: (
|
node: (
|
||||||
<>
|
<>
|
||||||
<ToolbarIconBtn label="Imprimer" onClick={() => window.print()}>
|
<ToolbarIconBtn label="Imprimer" onClick={() => onPrint?.() ?? window.print()}>
|
||||||
<Printer className="size-4" />
|
<Printer className="size-4" />
|
||||||
</ToolbarIconBtn>
|
</ToolbarIconBtn>
|
||||||
<ToolbarIconBtn
|
<ToolbarIconBtn
|
||||||
@ -260,33 +239,19 @@ function DocsToolbarInner({
|
|||||||
{
|
{
|
||||||
id: "style",
|
id: "style",
|
||||||
sepAfter: true,
|
sepAfter: true,
|
||||||
node: (
|
node: paragraphStylesCtx ? (
|
||||||
<Select
|
<DocsParagraphStyleSelect
|
||||||
value={styleId}
|
value={styleId}
|
||||||
onValueChange={(v) => applyTextStyle(editor, v)}
|
|
||||||
disabled={disabled}
|
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">
|
<SelectTrigger className="docs-toolbar-select h-7 w-[120px] shrink-0 border-0 bg-transparent px-1 shadow-none">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</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>
|
</Select>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -297,16 +262,16 @@ function DocsToolbarInner({
|
|||||||
<Select
|
<Select
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
value={fontFamilyState.kind === "single" ? fontFamilyState.name : undefined}
|
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
|
<SelectTrigger
|
||||||
className="docs-toolbar-select h-7 w-[108px] shrink-0 border-0 bg-transparent px-1 shadow-none"
|
className="docs-toolbar-select h-7 w-[108px] shrink-0 border-0 bg-transparent px-1 shadow-none"
|
||||||
style={
|
style={
|
||||||
fontFamilyState.kind === "single"
|
fontFamilyState.kind === "single"
|
||||||
? {
|
? {
|
||||||
fontFamily: DOCS_FONT_FAMILIES.find(
|
fontFamily: docsFontStackByName(fonts, fontFamilyState.name),
|
||||||
(f) => f.name === fontFamilyState.name
|
|
||||||
)?.stack,
|
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@ -314,12 +279,9 @@ function DocsToolbarInner({
|
|||||||
<SelectValue placeholder="Police" />
|
<SelectValue placeholder="Police" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="docs-toolbar-select-content docs-toolbar-select-content--font">
|
<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}>
|
<SelectItem key={f.name} value={f.name}>
|
||||||
<span
|
<span className="docs-toolbar-font-preview" style={{ fontFamily: f.stack }}>
|
||||||
className="docs-toolbar-font-preview"
|
|
||||||
style={{ fontFamily: f.stack }}
|
|
||||||
>
|
|
||||||
{f.name}
|
{f.name}
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@ -429,38 +391,7 @@ function DocsToolbarInner({
|
|||||||
sepAfter: false,
|
sepAfter: false,
|
||||||
node: (
|
node: (
|
||||||
<>
|
<>
|
||||||
<Popover open={linkOpen} onOpenChange={setLinkOpen}>
|
<DocsLinkPopover editor={editor} disabled={disabled} active={isLink} />
|
||||||
<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>
|
|
||||||
<ToolbarIconBtn disabled label="Commentaire (bientôt)">
|
<ToolbarIconBtn disabled label="Commentaire (bientôt)">
|
||||||
<Icon icon="material-symbols:add-comment-outline" className="size-4" />
|
<Icon icon="material-symbols:add-comment-outline" className="size-4" />
|
||||||
</ToolbarIconBtn>
|
</ToolbarIconBtn>
|
||||||
@ -470,17 +401,7 @@ function DocsToolbarInner({
|
|||||||
{
|
{
|
||||||
id: "insert-graphic",
|
id: "insert-graphic",
|
||||||
sepAfter: true,
|
sepAfter: true,
|
||||||
node: (
|
node: <DocsGraphicInsertMenu editor={editor} disabled={disabled} />,
|
||||||
<>
|
|
||||||
<DocsGraphicInsertMenu editor={editor} disabled={disabled} />
|
|
||||||
{graphicSelected ? (
|
|
||||||
<>
|
|
||||||
<DocsGraphicLayoutMenu editor={editor} disabled={disabled} />
|
|
||||||
<DocsGraphicOptionsPanel editor={editor} disabled={disabled} />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "align",
|
id: "align",
|
||||||
@ -532,8 +453,24 @@ function DocsToolbarInner({
|
|||||||
<Icon icon="material-symbols:format-line-spacing" className="size-4" />
|
<Icon icon="material-symbols:format-line-spacing" className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start" className="min-w-[220px]">
|
||||||
<DropdownMenuItem disabled>Bientôt disponible</DropdownMenuItem>
|
{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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</>
|
</>
|
||||||
@ -547,7 +484,10 @@ function DocsToolbarInner({
|
|||||||
<ToolbarIconBtn
|
<ToolbarIconBtn
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
active={isBulletList}
|
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"
|
label="Liste à puces"
|
||||||
>
|
>
|
||||||
<List className="size-4" />
|
<List className="size-4" />
|
||||||
@ -555,18 +495,37 @@ function DocsToolbarInner({
|
|||||||
<ToolbarIconBtn
|
<ToolbarIconBtn
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
active={isOrderedList}
|
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"
|
label="Liste numérotée"
|
||||||
>
|
>
|
||||||
<ListOrdered className="size-4" />
|
<ListOrdered className="size-4" />
|
||||||
</ToolbarIconBtn>
|
</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" />
|
<Icon icon="material-symbols:checklist" className="size-4" />
|
||||||
</ToolbarIconBtn>
|
</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" />
|
<Icon icon="material-symbols:format-indent-decrease" className="size-4" />
|
||||||
</ToolbarIconBtn>
|
</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" />
|
<Icon icon="material-symbols:format-indent-increase" className="size-4" />
|
||||||
</ToolbarIconBtn>
|
</ToolbarIconBtn>
|
||||||
</>
|
</>
|
||||||
@ -594,10 +553,8 @@ function DocsToolbarInner({
|
|||||||
onZoomChange,
|
onZoomChange,
|
||||||
spellcheck,
|
spellcheck,
|
||||||
onToggleSpellcheck,
|
onToggleSpellcheck,
|
||||||
graphicSelected,
|
paragraphStylesCtx,
|
||||||
linkOpen,
|
fonts,
|
||||||
linkUrl,
|
|
||||||
applyLink,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const { containerRef, measureRef, visibleCount, hasOverflow } = useToolbarOverflow(
|
const { containerRef, measureRef, visibleCount, hasOverflow } = useToolbarOverflow(
|
||||||
@ -688,7 +645,21 @@ function DocsToolbarInner({
|
|||||||
</div>
|
</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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -698,6 +669,14 @@ function DocsToolbarInner({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{toolbarRow}
|
{toolbarRow}
|
||||||
|
{editor ? (
|
||||||
|
<DocsLineSpacingDialog
|
||||||
|
open={customSpacingOpen}
|
||||||
|
onOpenChange={setCustomSpacingOpen}
|
||||||
|
initial={readDocsCustomSpacingDraft(editor)}
|
||||||
|
onApply={(input) => editor.chain().focus().setDocsCustomSpacing(input).run()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,7 +84,7 @@ function DocsVerticalRulerInner({
|
|||||||
{ticks.map((tick, index) => (
|
{ticks.map((tick, index) => (
|
||||||
<div
|
<div
|
||||||
key={`${tick.pos}-${index}`}
|
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={{
|
style={{
|
||||||
top: s(tick.pos),
|
top: s(tick.pos),
|
||||||
width: tick.major ? 10 : 5,
|
width: tick.major ? 10 : 5,
|
||||||
@ -97,7 +97,7 @@ function DocsVerticalRulerInner({
|
|||||||
.map((tick) => (
|
.map((tick) => (
|
||||||
<span
|
<span
|
||||||
key={`vlabel-${tick.pos}`}
|
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) }}
|
style={{ top: s(tick.pos) }}
|
||||||
>
|
>
|
||||||
{tick.label}
|
{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(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
const skipForDrive =
|
const skipForDrive =
|
||||||
|
pathname === "/" ||
|
||||||
|
pathname.startsWith("/demo/") ||
|
||||||
isDriveAppPath(pathname) ||
|
isDriveAppPath(pathname) ||
|
||||||
root.dataset.routeScope === "drive" ||
|
root.dataset.routeScope === "drive" ||
|
||||||
root.dataset.splashSeen === "1"
|
root.dataset.splashSeen === "1"
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { TrashView } from "./trash-view"
|
|||||||
import { BulkCreateDialog } from "./bulk-create-dialog"
|
import { BulkCreateDialog } from "./bulk-create-dialog"
|
||||||
import { ImportDialog } from "./import-dialog"
|
import { ImportDialog } from "./import-dialog"
|
||||||
import { CONTACTS_SHELL_CLASS } from "@/lib/contacts-chrome-classes"
|
import { CONTACTS_SHELL_CLASS } from "@/lib/contacts-chrome-classes"
|
||||||
|
import { AiChatPanel } from "@/components/ai/ai-chat-panel"
|
||||||
|
|
||||||
export type ContactsPageView =
|
export type ContactsPageView =
|
||||||
| "contacts"
|
| "contacts"
|
||||||
@ -194,6 +195,7 @@ export function ContactsAppShell() {
|
|||||||
|
|
||||||
<ImportDialog open={importOpen} onOpenChange={setImportOpen} />
|
<ImportDialog open={importOpen} onOpenChange={setImportOpen} />
|
||||||
<BulkCreateDialog open={bulkCreateOpen} onOpenChange={setBulkCreateOpen} onOpenImport={() => setImportOpen(true)} />
|
<BulkCreateDialog open={bulkCreateOpen} onOpenChange={setBulkCreateOpen} onOpenImport={() => setImportOpen(true)} />
|
||||||
|
<AiChatPanel />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
"use client"
|
"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 { Button } from "@/components/ui/button"
|
||||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||||
|
import { useAiPanelStore } from "@/lib/ai/use-ai-panel"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export function RightPanel() {
|
export function RightPanel() {
|
||||||
const { panelOpen, togglePanel } = useContactsStore()
|
const { panelOpen, togglePanel } = useContactsStore()
|
||||||
|
const aiOpen = useAiPanelStore((s) => s.open)
|
||||||
|
const openAiPanel = useAiPanelStore((s) => s.openPanel)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden w-10 shrink-0 flex-col items-center gap-2 bg-transparent py-3 sm:flex">
|
<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">
|
<Button variant="ghost" size="icon" className="h-9 w-9 text-gray-600 rounded-full">
|
||||||
<CheckSquare className="h-4 w-4" />
|
<CheckSquare className="h-4 w-4" />
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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 ?? "",
|
export_mirror_format: policy.richtext?.export_mirror_format ?? "",
|
||||||
hocuspocus_url: policy.richtext?.hocuspocus_url ?? "",
|
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 ?? [],
|
plugins: policy.plugins ?? [],
|
||||||
integrations: mergeIntegrations(policy.integrations as IntegrationEntry[]),
|
integrations: mergeIntegrations(policy.integrations as IntegrationEntry[]),
|
||||||
}
|
}
|
||||||
@ -187,6 +197,7 @@ export function storeToApiOrgPolicy(state: OrgSettingsState): ApiOrgPolicy {
|
|||||||
mailing: { ...state.mailing },
|
mailing: { ...state.mailing },
|
||||||
onlyoffice: { ...state.onlyoffice },
|
onlyoffice: { ...state.onlyoffice },
|
||||||
richtext: { ...state.richtext },
|
richtext: { ...state.richtext },
|
||||||
|
ai_assistant: { ...state.aiAssistant },
|
||||||
plugins: state.plugins.map(({ id, name, description, enabled, version }) => ({
|
plugins: state.plugins.map(({ id, name, description, enabled, version }) => ({
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import type {
|
|||||||
NextcloudSettings,
|
NextcloudSettings,
|
||||||
OnlyOfficeSettings,
|
OnlyOfficeSettings,
|
||||||
RichTextSettings,
|
RichTextSettings,
|
||||||
|
AiAssistantSettings,
|
||||||
OrgLLMSettings,
|
OrgLLMSettings,
|
||||||
OrgSearchSettings,
|
OrgSearchSettings,
|
||||||
OrgStorageQuotas,
|
OrgStorageQuotas,
|
||||||
@ -123,6 +124,17 @@ const DEFAULT_RICHTEXT: RichTextSettings = {
|
|||||||
hocuspocus_url: "",
|
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[] = [
|
const DEFAULT_PLUGINS: PluginEntry[] = [
|
||||||
{
|
{
|
||||||
id: "mail-automation",
|
id: "mail-automation",
|
||||||
@ -155,10 +167,17 @@ const DEFAULT_PLUGINS: PluginEntry[] = [
|
|||||||
{
|
{
|
||||||
id: "richtext-editor",
|
id: "richtext-editor",
|
||||||
name: "Édition rich text TipTap",
|
name: "Édition rich text TipTap",
|
||||||
description: "Éditeur texte collaboratif pour documents Word.",
|
description: "Édition rich text TipTap pour documents Word.",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
version: "1.0.0",
|
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[] = [
|
const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
|
||||||
@ -209,6 +228,7 @@ type OrgSettingsActions = {
|
|||||||
setMailing: (patch: Partial<MailingSettings>) => void
|
setMailing: (patch: Partial<MailingSettings>) => void
|
||||||
setOnlyoffice: (patch: Partial<OnlyOfficeSettings>) => void
|
setOnlyoffice: (patch: Partial<OnlyOfficeSettings>) => void
|
||||||
setRichtext: (patch: Partial<RichTextSettings>) => void
|
setRichtext: (patch: Partial<RichTextSettings>) => void
|
||||||
|
setAiAssistant: (patch: Partial<AiAssistantSettings>) => void
|
||||||
setAdministrators: (admins: Administrator[]) => void
|
setAdministrators: (admins: Administrator[]) => void
|
||||||
addAdministrator: (admin: Administrator) => void
|
addAdministrator: (admin: Administrator) => void
|
||||||
removeAdministrator: (id: string) => void
|
removeAdministrator: (id: string) => void
|
||||||
@ -231,6 +251,7 @@ type OrgSettingsActions = {
|
|||||||
mailing: MailingSettings
|
mailing: MailingSettings
|
||||||
onlyoffice: OnlyOfficeSettings
|
onlyoffice: OnlyOfficeSettings
|
||||||
richtext: RichTextSettings
|
richtext: RichTextSettings
|
||||||
|
aiAssistant: AiAssistantSettings
|
||||||
plugins: PluginEntry[]
|
plugins: PluginEntry[]
|
||||||
integrations: IntegrationEntry[]
|
integrations: IntegrationEntry[]
|
||||||
}>, meta?: OrgSettingsMeta) => void
|
}>, meta?: OrgSettingsMeta) => void
|
||||||
@ -251,6 +272,7 @@ export const useOrgSettingsStore = create<
|
|||||||
mailing: MailingSettings
|
mailing: MailingSettings
|
||||||
onlyoffice: OnlyOfficeSettings
|
onlyoffice: OnlyOfficeSettings
|
||||||
richtext: RichTextSettings
|
richtext: RichTextSettings
|
||||||
|
aiAssistant: AiAssistantSettings
|
||||||
plugins: PluginEntry[]
|
plugins: PluginEntry[]
|
||||||
integrations: IntegrationEntry[]
|
integrations: IntegrationEntry[]
|
||||||
meta: OrgSettingsMeta | null
|
meta: OrgSettingsMeta | null
|
||||||
@ -270,6 +292,7 @@ export const useOrgSettingsStore = create<
|
|||||||
mailing: DEFAULT_MAILING,
|
mailing: DEFAULT_MAILING,
|
||||||
onlyoffice: DEFAULT_ONLYOFFICE,
|
onlyoffice: DEFAULT_ONLYOFFICE,
|
||||||
richtext: DEFAULT_RICHTEXT,
|
richtext: DEFAULT_RICHTEXT,
|
||||||
|
aiAssistant: DEFAULT_AI_ASSISTANT,
|
||||||
plugins: DEFAULT_PLUGINS,
|
plugins: DEFAULT_PLUGINS,
|
||||||
integrations: DEFAULT_INTEGRATIONS,
|
integrations: DEFAULT_INTEGRATIONS,
|
||||||
meta: null,
|
meta: null,
|
||||||
@ -299,6 +322,8 @@ export const useOrgSettingsStore = create<
|
|||||||
set((s) => ({ onlyoffice: { ...s.onlyoffice, ...patch } })),
|
set((s) => ({ onlyoffice: { ...s.onlyoffice, ...patch } })),
|
||||||
setRichtext: (patch) =>
|
setRichtext: (patch) =>
|
||||||
set((s) => ({ richtext: { ...s.richtext, ...patch } })),
|
set((s) => ({ richtext: { ...s.richtext, ...patch } })),
|
||||||
|
setAiAssistant: (patch) =>
|
||||||
|
set((s) => ({ aiAssistant: { ...s.aiAssistant, ...patch } })),
|
||||||
setAdministrators: (administrators) => set({ administrators }),
|
setAdministrators: (administrators) => set({ administrators }),
|
||||||
addAdministrator: (admin) =>
|
addAdministrator: (admin) =>
|
||||||
set((s) => ({ administrators: [...s.administrators, admin] })),
|
set((s) => ({ administrators: [...s.administrators, admin] })),
|
||||||
|
|||||||
@ -172,6 +172,17 @@ export type RichTextSettings = {
|
|||||||
hocuspocus_url: string
|
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 = {
|
export type PluginEntry = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@ -203,6 +214,7 @@ export type OrgSettingsState = {
|
|||||||
mailing: MailingSettings
|
mailing: MailingSettings
|
||||||
onlyoffice: OnlyOfficeSettings
|
onlyoffice: OnlyOfficeSettings
|
||||||
richtext: RichTextSettings
|
richtext: RichTextSettings
|
||||||
|
aiAssistant: AiAssistantSettings
|
||||||
plugins: PluginEntry[]
|
plugins: PluginEntry[]
|
||||||
integrations: IntegrationEntry[]
|
integrations: IntegrationEntry[]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,6 +34,7 @@ export type AdminSettingsSectionId =
|
|||||||
| "mailing"
|
| "mailing"
|
||||||
| "onlyoffice"
|
| "onlyoffice"
|
||||||
| "richtext"
|
| "richtext"
|
||||||
|
| "ai-assistant"
|
||||||
| "audit"
|
| "audit"
|
||||||
|
|
||||||
export type AdminSettingsNavItem = {
|
export type AdminSettingsNavItem = {
|
||||||
@ -150,6 +151,13 @@ export const ADMIN_SETTINGS_NAV: AdminSettingsNavItem[] = [
|
|||||||
href: "/admin/settings/richtext",
|
href: "/admin/settings/richtext",
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "ai-assistant",
|
||||||
|
label: "UltiAI",
|
||||||
|
description: "Assistant IA intégré et tools suite",
|
||||||
|
href: "/admin/settings/ai-assistant",
|
||||||
|
icon: Bot,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "audit",
|
id: "audit",
|
||||||
label: "Journal d'audit",
|
label: "Journal d'audit",
|
||||||
|
|||||||
@ -22,6 +22,7 @@ const GROUP_LABELS: Record<string, string> = {
|
|||||||
authentik: "Authentik / OIDC",
|
authentik: "Authentik / OIDC",
|
||||||
nextcloud: "Nextcloud",
|
nextcloud: "Nextcloud",
|
||||||
onlyoffice: "OnlyOffice",
|
onlyoffice: "OnlyOffice",
|
||||||
|
ai_assistant: "UltiAI",
|
||||||
search: "Recherche",
|
search: "Recherche",
|
||||||
immich: "Immich",
|
immich: "Immich",
|
||||||
jitsi: "Jitsi Meet",
|
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
|
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 = {
|
export type ApiOrgPlugin = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@ -189,6 +200,7 @@ export type ApiOrgPolicy = {
|
|||||||
mailing: ApiOrgMailing
|
mailing: ApiOrgMailing
|
||||||
onlyoffice: ApiOrgOnlyoffice
|
onlyoffice: ApiOrgOnlyoffice
|
||||||
richtext?: ApiOrgRichText
|
richtext?: ApiOrgRichText
|
||||||
|
ai_assistant?: ApiOrgAiAssistant
|
||||||
plugins: ApiOrgPlugin[]
|
plugins: ApiOrgPlugin[]
|
||||||
integrations: ApiOrgIntegration[]
|
integrations: ApiOrgIntegration[]
|
||||||
}
|
}
|
||||||
@ -222,6 +234,11 @@ export type ApiOrgEffective = {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
public_url: string
|
public_url: string
|
||||||
}
|
}
|
||||||
|
ai_assistant?: {
|
||||||
|
enabled: boolean
|
||||||
|
openwebui_internal_url: string
|
||||||
|
public_path: string
|
||||||
|
}
|
||||||
identity_providers?: {
|
identity_providers?: {
|
||||||
authentik_public_url: string
|
authentik_public_url: string
|
||||||
oauth_redirect_template: 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({
|
const createFile = useMutation({
|
||||||
mutationFn: (body: { parent_path: string; name: string; kind: string }) =>
|
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,
|
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 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 =
|
export type DocsDownloadFormat =
|
||||||
| "docx"
|
| "docx"
|
||||||
@ -23,53 +29,46 @@ export const DOCS_DOWNLOAD_FORMATS: { id: DocsDownloadFormat; label: string }[]
|
|||||||
{ id: "md", label: "Markdown (.md)" },
|
{ id: "md", label: "Markdown (.md)" },
|
||||||
]
|
]
|
||||||
|
|
||||||
function downloadBlob(blob: Blob, fileName: string) {
|
export { downloadBlob, exportFileName } from "@/lib/drive/docs-export-download"
|
||||||
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 async function exportDocsContent(
|
export async function exportDocsContent(
|
||||||
format: DocsDownloadFormat,
|
format: DocsDownloadFormat,
|
||||||
|
snapshot: DocsExportSnapshot | null,
|
||||||
editor: Editor | null,
|
editor: Editor | null,
|
||||||
sourceName: string
|
sourceName: string
|
||||||
): Promise<"done" | "unsupported"> {
|
): Promise<"done" | "unsupported"> {
|
||||||
if (!editor) return "unsupported"
|
if (!editor && !snapshot) return "unsupported"
|
||||||
|
|
||||||
const content = editor.getJSON() as TipTapJSON
|
const base = baseNameFromSource(sourceName)
|
||||||
const base = baseNameWithoutExt(sourceName)
|
|
||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case "docx": {
|
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"))
|
downloadBlob(blob, exportFileName(sourceName, "docx"))
|
||||||
return "done"
|
return "done"
|
||||||
}
|
}
|
||||||
|
case "pdf": {
|
||||||
|
if (!snapshot) return "unsupported"
|
||||||
|
const { exportDocsToPdf } = await import("@/lib/drive/docs-pdf-export")
|
||||||
|
await exportDocsToPdf(snapshot)
|
||||||
|
return "done"
|
||||||
|
}
|
||||||
case "txt": {
|
case "txt": {
|
||||||
|
if (!editor) return "unsupported"
|
||||||
const text = editor.getText()
|
const text = editor.getText()
|
||||||
downloadBlob(new Blob([text], { type: "text/plain;charset=utf-8" }), `${base}.txt`)
|
downloadBlob(new Blob([text], { type: "text/plain;charset=utf-8" }), `${base}.txt`)
|
||||||
return "done"
|
return "done"
|
||||||
}
|
}
|
||||||
case "md": {
|
case "md": {
|
||||||
|
if (!editor) return "unsupported"
|
||||||
const text = editor.getText()
|
const text = editor.getText()
|
||||||
downloadBlob(new Blob([text], { type: "text/markdown;charset=utf-8" }), `${base}.md`)
|
downloadBlob(new Blob([text], { type: "text/markdown;charset=utf-8" }), `${base}.md`)
|
||||||
return "done"
|
return "done"
|
||||||
}
|
}
|
||||||
case "html": {
|
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>`
|
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`)
|
downloadBlob(new Blob([html], { type: "text/html;charset=utf-8" }), `${base}.html`)
|
||||||
return "done"
|
return "done"
|
||||||
@ -78,3 +77,5 @@ export async function exportDocsContent(
|
|||||||
return "unsupported"
|
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