This commit is contained in:
R3D347HR4Y 2026-06-09 17:06:20 +02:00
parent cdff12490a
commit 5b1cc5e83c
41 changed files with 3319 additions and 263 deletions

View File

@ -0,0 +1,17 @@
import type { Metadata } from "next"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export async function generateMetadata(): Promise<Metadata> {
return suitePageMetadata({
app: "drive",
titleSegment: "Document",
})
}
export default function DriveDocsEditLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

View File

@ -0,0 +1,20 @@
"use client"
import { useParams } from "next/navigation"
import { RichTextEditor } from "@/components/drive/richtext-editor"
import { isDriveFileIdSegment } from "@/lib/drive/drive-url"
export default function DriveDocsEditPage() {
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 document invalide
</div>
)
}
return <RichTextEditor fileId={fileId} />
}

View File

@ -2,21 +2,32 @@
import { useParams, useSearchParams } from "next/navigation" import { useParams, useSearchParams } from "next/navigation"
import { OfficeEditor } from "@/components/drive/office-editor" import { OfficeEditor } from "@/components/drive/office-editor"
import { RichTextEditor } from "@/components/drive/richtext-editor" import { RichTextEditorLegacyRedirect } from "@/components/drive/richtext-legacy-redirect"
import { shouldOpenInRichTextEditor } from "@/lib/drive/drive-preview" import { shouldOpenInRichTextEditor } from "@/lib/drive/drive-preview"
import { isDriveFileIdSegment } from "@/lib/drive/drive-url"
export default function DriveEditPage() { export default function DriveEditPage() {
const params = useParams() const params = useParams()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const filePath = decodeURIComponent(params.fileId as string) const rawId = params.fileId as string
const returnTo = searchParams.get("returnTo") const returnTo = searchParams.get("returnTo")
const editorParam = searchParams.get("editor") const editorParam = searchParams.get("editor")
if (isDriveFileIdSegment(rawId)) {
return (
<div className="flex h-dvh items-center justify-center p-8 text-sm text-muted-foreground">
Ouvrez ce document via /drive/docs/{rawId}/edit
</div>
)
}
const filePath = decodeURIComponent(rawId)
const fileName = filePath.split("/").pop() ?? filePath const fileName = filePath.split("/").pop() ?? filePath
const useRichText = const useRichText =
editorParam === "richtext" || shouldOpenInRichTextEditor({ name: fileName }) editorParam === "richtext" || shouldOpenInRichTextEditor({ name: fileName })
if (useRichText) { if (useRichText) {
return <RichTextEditor filePath={filePath} returnTo={returnTo} /> return <RichTextEditorLegacyRedirect filePath={filePath} returnTo={returnTo} />
} }
return <OfficeEditor filePath={filePath} returnTo={returnTo} /> return <OfficeEditor filePath={filePath} returnTo={returnTo} />

View File

@ -1,8 +1,14 @@
import type { Metadata } from "next" import type { Metadata } from "next"
import { DriveRouteScope } from "@/components/drive/drive-route-scope"
import { suitePageMetadata } from "@/lib/suite/page-metadata" import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = suitePageMetadata({ app: "drive" }) export const metadata: Metadata = suitePageMetadata({ app: "drive" })
export default function DriveRootLayout({ children }: { children: React.ReactNode }) { export default function DriveRootLayout({ children }: { children: React.ReactNode }) {
return children return (
<>
<DriveRouteScope />
{children}
</>
)
} }

View File

@ -595,6 +595,26 @@ html:has(.ultimail-login) body {
background-color: transparent !important; background-color: transparent !important;
} }
/* ── Drive : pas de fond décoratif mail ni splash Ultimail (y compris chargement) ── */
html[data-route-scope='drive']::before,
html:has([data-drive-app])::before {
opacity: 0 !important;
}
html[data-route-scope='drive'] {
background-color: var(--app-canvas);
}
html[data-route-scope='drive'] body {
background-color: var(--app-canvas) !important;
}
html[data-route-scope='drive'] .app-first-launch-splash {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
@media (min-width: 640px) { @media (min-width: 640px) {
.ultimail-login-card-frame { .ultimail-login-card-frame {
padding: 3px; padding: 3px;

View File

@ -46,7 +46,7 @@ export function DriveMoveDialog({
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
sources: DriveFileInfo[] sources: DriveFileInfo[]
onMoved?: () => void onMoved?: (destinationFolder?: string) => void
mode?: DriveFolderPickerMode mode?: DriveFolderPickerMode
}) { }) {
const [browsePath, setBrowsePath] = useState("/") const [browsePath, setBrowsePath] = useState("/")
@ -99,7 +99,7 @@ export function DriveMoveDialog({
toast.success(sources.length > 1 ? "Éléments déplacés" : "Élément déplacé") toast.success(sources.length > 1 ? "Éléments déplacés" : "Élément déplacé")
} }
onOpenChange(false) onOpenChange(false)
onMoved?.() onMoved?.(browsePath)
} catch { } catch {
toast.error(isCopy ? "Impossible de copier" : "Impossible de déplacer") toast.error(isCopy ? "Impossible de copier" : "Impossible de déplacer")
} }

View File

@ -0,0 +1,20 @@
"use client"
import { useEffect } from "react"
import { clearMailBackgroundDom } from "@/lib/mail-settings/mail-background-dom"
/** Marks document as Drive scope: no mail wallpaper, no first-launch splash. */
export function DriveRouteScope() {
useEffect(() => {
const html = document.documentElement
html.dataset.routeScope = "drive"
html.dataset.splashSeen = "1"
clearMailBackgroundDom(html)
return () => {
delete html.dataset.routeScope
}
}, [])
return null
}

View File

@ -3,6 +3,9 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const FIELD_BASE =
"col-start-1 row-start-1 w-full min-w-0 rounded-md border border-transparent bg-transparent px-2 py-0.5 text-left text-sm font-medium outline-none transition-[background-color,border-color]"
export function OfficeEditorInlineTitle({ export function OfficeEditorInlineTitle({
value, value,
onRename, onRename,
@ -64,16 +67,27 @@ export function OfficeEditorInlineTitle({
} }
} }
if (editing && !disabled) { const mirrorText = (editing ? draft : value) || " "
const fieldClass = cn(FIELD_BASE, className)
return ( return (
<div className="inline-grid w-fit max-w-[min(480px,45vw)]">
<span
className={cn(fieldClass, "invisible whitespace-pre")}
aria-hidden
>
{mirrorText}
</span>
{editing && !disabled ? (
<input <input
ref={inputRef} ref={inputRef}
value={draft} value={draft}
disabled={busy} disabled={busy}
aria-label="Nom du fichier" aria-label="Nom du fichier"
className={cn( className={cn(
"h-8 min-w-0 max-w-[min(420px,50vw)] rounded-md border border-border bg-background px-2 text-sm font-medium text-foreground outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring", fieldClass,
className "text-foreground focus-visible:border-border focus-visible:ring-0"
)} )}
onChange={(event) => setDraft(event.target.value)} onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
@ -88,17 +102,17 @@ export function OfficeEditorInlineTitle({
}} }}
onBlur={() => void commitEdit()} onBlur={() => void commitEdit()}
/> />
) ) : (
}
return (
<button <button
type="button" type="button"
disabled={disabled || busy} disabled={disabled || busy}
title={disabled ? undefined : "Renommer"} title={disabled ? undefined : "Renommer"}
className={cn( className={cn(
"min-w-0 truncate rounded-md px-1.5 py-1 text-left text-sm font-medium text-foreground transition-colors hover:bg-muted/70 disabled:cursor-default disabled:hover:bg-transparent", fieldClass,
className "truncate text-foreground",
disabled
? "cursor-default"
: "cursor-text hover:bg-muted/70 focus-visible:border-border focus-visible:ring-0"
)} )}
onClick={() => { onClick={() => {
if (!disabled && !busy) setEditing(true) if (!disabled && !busy) setEditing(true)
@ -106,5 +120,7 @@ export function OfficeEditorInlineTitle({
> >
{value} {value}
</button> </button>
)}
</div>
) )
} }

View File

@ -4,35 +4,20 @@ 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, Loader2 } from "lucide-react"
import { OfficeEditorChrome } from "@/components/drive/office-editor-chrome"
import { RichTextDocumentEditor } from "@/components/drive/richtext-document" import { RichTextDocumentEditor } from "@/components/drive/richtext-document"
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"
import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title" import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title"
import { getGuestEditorIdentity } from "@/lib/drive/guest-editor-identity" import { getGuestEditorIdentity } from "@/lib/drive/guest-editor-identity"
import type { RichTextSaveStatus, RichTextSessionResponse } from "@/lib/drive/richtext-types" import type { RichTextSessionResponse } from "@/lib/drive/richtext-types"
import { fetchPublicShareBlob } from "@/lib/api/public-share" import { fetchPublicShareBlob } from "@/lib/api/public-share"
import { cn } from "@/lib/utils"
function fileNameFromPath(filePath: string, fallback?: string): string { function fileNameFromPath(filePath: string, fallback?: string): string {
const base = filePath.split("/").filter(Boolean).pop() const base = filePath.split("/").filter(Boolean).pop()
return base || fallback || filePath return base || fallback || filePath
} }
function saveStatusLabel(status: RichTextSaveStatus): string {
switch (status) {
case "saving":
return "Enregistrement…"
case "saved":
return "Enregistré"
case "error":
return "Erreur d'enregistrement"
default:
return ""
}
}
export function PublicRichTextEditor({ export function PublicRichTextEditor({
token, token,
filePath, filePath,
@ -53,7 +38,6 @@ export function PublicRichTextEditor({
const guest = useMemo(() => getGuestEditorIdentity(token), [token]) const guest = useMemo(() => getGuestEditorIdentity(token), [token])
const [session, setSession] = useState<RichTextSessionResponse | null>(null) const [session, setSession] = useState<RichTextSessionResponse | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [saveStatus, setSaveStatus] = useState<RichTextSaveStatus>("idle")
const [resolvedMode, setResolvedMode] = useState<"edit" | "view">(mode) const [resolvedMode, setResolvedMode] = useState<"edit" | "view">(mode)
const fileName = fileDisplayName || fileNameFromPath(filePath) const fileName = fileDisplayName || fileNameFromPath(filePath)
@ -129,7 +113,28 @@ export function PublicRichTextEditor({
[token, password, fileName] [token, password, fileName]
) )
const statusText = saveStatusLabel(saveStatus) const chrome = useMemo(
() => ({
title,
backHref,
backLabel: "Partage",
showBack,
showShare: false,
showAccount: false,
trailing:
resolvedMode === "view" ? (
<span className="text-xs text-muted-foreground">Lecture seule</span>
) : (
<span
className="rounded px-2 py-1 text-xs font-semibold text-white"
style={{ backgroundColor: guest.color }}
>
{guest.guestName}
</span>
),
}),
[title, backHref, showBack, resolvedMode, guest.color, guest.guestName]
)
if (error) { if (error) {
return ( return (
@ -148,39 +153,7 @@ export function PublicRichTextEditor({
} }
return ( return (
<div className="flex h-full min-h-0 flex-col bg-background"> <div className="flex h-dvh min-h-0 flex-col">
<OfficeEditorChrome
backHref={backHref}
backLabel="Partage"
showBack={showBack}
title={title}
showShare={false}
showAccount={false}
trailing={
<div className="flex items-center gap-3">
<span
className="rounded px-2 py-1 text-xs font-semibold text-white"
style={{ backgroundColor: guest.color }}
>
Vous êtes {guest.guestName}
</span>
{resolvedMode === "view" ? (
<span className="text-xs text-muted-foreground">Lecture seule</span>
) : null}
{statusText ? (
<span
className={cn(
"text-xs text-muted-foreground",
saveStatus === "error" && "text-destructive"
)}
>
{statusText}
</span>
) : null}
</div>
}
/>
<div className="min-h-0 flex-1">
{!session ? ( {!session ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
@ -191,12 +164,11 @@ export function PublicRichTextEditor({
mode={resolvedMode} mode={resolvedMode}
userName={guest.guestName} userName={guest.guestName}
userColor={guest.color} userColor={guest.color}
onSaveStatus={setSaveStatus}
fetchSourceBytes={fetchSourceBytes} fetchSourceBytes={fetchSourceBytes}
importApi={importApi} importApi={importApi}
chrome={chrome}
/> />
)} )}
</div> </div>
</div>
) )
} }

View File

@ -1,20 +1,42 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { ReactNode } from "react"
import { useEditor, EditorContent } from "@tiptap/react" import { useEditor, EditorContent } from "@tiptap/react"
import { HocuspocusProvider } from "@hocuspocus/provider" import { HocuspocusProvider } from "@hocuspocus/provider"
import * as Y from "yjs" import * as Y from "yjs"
import { DocsChrome } from "@/components/drive/richtext/docs-chrome"
import { DocsPageView, DocsStatusBar } from "@/components/drive/richtext/docs-page-view"
import { DocsToolbar } from "@/components/drive/richtext/docs-toolbar"
import { buildRichTextExtensions, RICHTEXT_EDITOR_CLASS } from "@/lib/drive/richtext-extensions" import { buildRichTextExtensions, RICHTEXT_EDITOR_CLASS } from "@/lib/drive/richtext-extensions"
import type { RichTextSaveStatus, RichTextSessionResponse } from "@/lib/drive/richtext-types" import type { RichTextSaveStatus, RichTextSessionResponse } from "@/lib/drive/richtext-types"
import type { DriveShare, DriveFileInfo } from "@/lib/api/types"
import { useDocsViewSettings } from "@/lib/drive/docs-view-settings"
import { useCollabPresence } from "@/lib/drive/use-collab-presence"
import { apiClient } from "@/lib/api/client" import { apiClient } from "@/lib/api/client"
import { driveDownloadApiPath } from "@/lib/api/drive-download" import { driveDownloadApiPath } from "@/lib/api/drive-download"
import { importFileToTipTap } from "@/lib/drive/richtext-import" import { importFileToTipTap } from "@/lib/drive/richtext-import"
import { RichTextToolbar } from "@/components/drive/richtext-toolbar"
const SAVE_DEBOUNCE_MS = 2000 const SAVE_DEBOUNCE_MS = 2000
/** Align with Hocuspocus store debounce + buffer */ /** Align with Hocuspocus store debounce + buffer */
const COLLAB_SAVE_IDLE_MS = 2000 const COLLAB_SAVE_IDLE_MS = 2000
export type RichTextDocsChromeProps = {
title: string
onRename?: (next: string) => Promise<void>
renameDisabled?: boolean
backHref?: string
backLabel?: string
showBack?: boolean
shares?: DriveShare[]
onShareClick?: () => void
showShare?: boolean
showAccount?: boolean
trailing?: ReactNode
moveFile?: DriveFileInfo
onFileMoved?: (newPath: string) => void
}
export function RichTextDocumentEditor({ export function RichTextDocumentEditor({
session, session,
mode, mode,
@ -23,6 +45,7 @@ export function RichTextDocumentEditor({
onSaveStatus, onSaveStatus,
fetchSourceBytes, fetchSourceBytes,
importApi, importApi,
chrome,
}: { }: {
session: RichTextSessionResponse session: RichTextSessionResponse
mode: "edit" | "view" mode: "edit" | "view"
@ -31,6 +54,7 @@ export function RichTextDocumentEditor({
onSaveStatus?: (status: RichTextSaveStatus) => void onSaveStatus?: (status: RichTextSaveStatus) => void
fetchSourceBytes?: (path: string) => Promise<ArrayBuffer> fetchSourceBytes?: (path: string) => Promise<ArrayBuffer>
importApi?: (body: { source_path: string; content: Record<string, unknown> }) => Promise<void> importApi?: (body: { source_path: string; content: Record<string, unknown> }) => Promise<void>
chrome?: RichTextDocsChromeProps
}) { }) {
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)
@ -44,16 +68,38 @@ export function RichTextDocumentEditor({
const [collabSynced, setCollabSynced] = useState(false) const [collabSynced, setCollabSynced] = useState(false)
const [collabError, setCollabError] = useState<string | null>(null) const [collabError, setCollabError] = useState<string | null>(null)
const [importDone, setImportDone] = useState(!session.importRequired) const [importDone, setImportDone] = useState(!session.importRequired)
const [saveStatus, setSaveStatus] = useState<RichTextSaveStatus>("idle")
const [pageCount, setPageCount] = useState(1)
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null) const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const saveIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null) const saveIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const saveStatusRef = useRef<RichTextSaveStatus>("idle")
const { settings, setPageFormatId, setZoom, toggleSpellcheck, toggleChromeCollapsed } =
useDocsViewSettings()
const presenceUsers = useCollabPresence(provider, { name: userName, color: userColor })
const reportSaveStatus = useCallback(
(status: RichTextSaveStatus) => {
saveStatusRef.current = status
setSaveStatus(status)
onSaveStatus?.(status)
},
[onSaveStatus]
)
const handlePageCountChange = useCallback((count: number) => {
setPageCount(count)
}, [])
const markCollabDirty = useCallback(() => { const markCollabDirty = useCallback(() => {
onSaveStatus?.("saving") if (saveStatusRef.current !== "saving") {
reportSaveStatus("saving")
}
if (saveIdleTimer.current) clearTimeout(saveIdleTimer.current) if (saveIdleTimer.current) clearTimeout(saveIdleTimer.current)
saveIdleTimer.current = setTimeout(() => { saveIdleTimer.current = setTimeout(() => {
onSaveStatus?.("saved") reportSaveStatus("saved")
}, COLLAB_SAVE_IDLE_MS) }, COLLAB_SAVE_IDLE_MS)
}, [onSaveStatus]) }, [reportSaveStatus])
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -66,7 +112,7 @@ export function RichTextDocumentEditor({
if (!session.importRequired || importDone) return if (!session.importRequired || importDone) return
let cancelled = false let cancelled = false
void (async () => { void (async () => {
onSaveStatus?.("saving") reportSaveStatus("saving")
try { try {
const source = session.sourcePath || session.canonicalPath const source = session.sourcePath || session.canonicalPath
const buf = fetchSourceBytes const buf = fetchSourceBytes
@ -82,16 +128,16 @@ export function RichTextDocumentEditor({
} }
if (!cancelled) { if (!cancelled) {
setImportDone(true) setImportDone(true)
onSaveStatus?.("saved") reportSaveStatus("saved")
} }
} catch { } catch {
if (!cancelled) onSaveStatus?.("error") if (!cancelled) reportSaveStatus("error")
} }
})() })()
return () => { return () => {
cancelled = true cancelled = true
} }
}, [session, importDone, fetchSourceBytes, importApi, onSaveStatus]) }, [session, importDone, fetchSourceBytes, importApi, reportSaveStatus])
useEffect(() => { useEffect(() => {
if (!collaboration || !ydoc || !importDone) return if (!collaboration || !ydoc || !importDone) return
@ -124,7 +170,9 @@ export function RichTextDocumentEditor({
(json: Record<string, unknown>) => { (json: Record<string, unknown>) => {
if (!editable || collaboration) return if (!editable || collaboration) return
if (saveTimer.current) clearTimeout(saveTimer.current) if (saveTimer.current) clearTimeout(saveTimer.current)
onSaveStatus?.("saving") if (saveStatusRef.current !== "saving") {
reportSaveStatus("saving")
}
saveTimer.current = setTimeout(() => { saveTimer.current = setTimeout(() => {
const doc = { schemaVersion: 1, editor: "tiptap", content: json } const doc = { schemaVersion: 1, editor: "tiptap", content: json }
const body = JSON.stringify(doc) const body = JSON.stringify(doc)
@ -138,11 +186,11 @@ export function RichTextDocumentEditor({
}) })
: apiClient.put("/richtext/save", { path: session.canonicalPath, document: json }) : apiClient.put("/richtext/save", { path: session.canonicalPath, document: json })
void savePromise void savePromise
.then(() => onSaveStatus?.("saved")) .then(() => reportSaveStatus("saved"))
.catch(() => onSaveStatus?.("error")) .catch(() => reportSaveStatus("error"))
}, SAVE_DEBOUNCE_MS) }, SAVE_DEBOUNCE_MS)
}, },
[collaboration, editable, onSaveStatus, session.canonicalPath, session.saveUrl] [collaboration, editable, reportSaveStatus, session.canonicalPath, session.saveUrl]
) )
const collabReady = !collaboration || (Boolean(provider) && collabSynced) const collabReady = !collaboration || (Boolean(provider) && collabSynced)
@ -166,7 +214,11 @@ export function RichTextDocumentEditor({
immediatelyRender: false, immediatelyRender: false,
editable, editable,
extensions, extensions,
editorProps: { attributes: { class: RICHTEXT_EDITOR_CLASS } }, editorProps: {
attributes: {
class: RICHTEXT_EDITOR_CLASS,
},
},
onUpdate: ({ editor: ed }) => { onUpdate: ({ editor: ed }) => {
if (collaboration) { if (collaboration) {
markCollabDirty() markCollabDirty()
@ -178,6 +230,31 @@ export function RichTextDocumentEditor({
[editorEnabled, extensions, collaboration, markCollabDirty, scheduleSave] [editorEnabled, extensions, collaboration, markCollabDirty, scheduleSave]
) )
useEffect(() => {
if (!editor || editor.isDestroyed) return
const syncSpellcheck = () => {
if (editor.isDestroyed || !editor.isInitialized) return
const dom = editor.view.dom
dom.spellcheck = settings.spellcheck
if (settings.spellcheck) {
dom.setAttribute("spellcheck", "true")
dom.removeAttribute("autocorrect")
dom.removeAttribute("autocapitalize")
} else {
dom.setAttribute("spellcheck", "false")
dom.setAttribute("autocorrect", "off")
dom.setAttribute("autocapitalize", "off")
}
}
syncSpellcheck()
editor.on("create", syncSpellcheck)
return () => {
editor.off("create", syncSpellcheck)
}
}, [editor, settings.spellcheck])
useEffect(() => { useEffect(() => {
if (!editor || collaboration || !importDone || session.importRequired) return if (!editor || collaboration || !importDone || session.importRequired) return
let cancelled = false let cancelled = false
@ -218,18 +295,66 @@ export function RichTextDocumentEditor({
? "Connexion à la collaboration…" ? "Connexion à la collaboration…"
: "Connexion…" : "Connexion…"
return ( return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground"> <div className="flex h-full flex-col">
{chrome && !settings.chromeCollapsed ? (
<DocsChrome
{...chrome}
saveStatus={saveStatus}
presenceUsers={presenceUsers}
pageFormatId={settings.pageFormatId}
onPageFormatChange={setPageFormatId}
zoom={settings.zoom}
onZoomChange={setZoom}
/>
) : null}
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{statusText} {statusText}
</div> </div>
</div>
) )
} }
return ( return (
<div className="flex h-full min-h-0 flex-col"> <div className="flex h-full min-h-0 flex-col bg-white dark:bg-background">
{editable ? <RichTextToolbar editor={editor} /> : null} {chrome && !settings.chromeCollapsed ? (
<DocsChrome
{...chrome}
saveStatus={saveStatus}
presenceUsers={presenceUsers}
pageFormatId={settings.pageFormatId}
onPageFormatChange={setPageFormatId}
zoom={settings.zoom}
onZoomChange={setZoom}
/>
) : null}
{editable ? (
<DocsToolbar
editor={editor}
zoom={settings.zoom}
onZoomChange={setZoom}
spellcheck={settings.spellcheck}
onToggleSpellcheck={toggleSpellcheck}
showChromeToggle={Boolean(chrome)}
chromeCollapsed={settings.chromeCollapsed}
onToggleChromeCollapsed={toggleChromeCollapsed}
/>
) : null}
{chrome ? (
<DocsPageView
editor={editor}
pageFormatId={settings.pageFormatId}
zoom={settings.zoom}
editable={editable}
onPageCountChange={handlePageCountChange}
/>
) : (
<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" />
</div> </div>
)}
{chrome ? (
<DocsStatusBar pageFormatId={settings.pageFormatId} pageCount={pageCount} />
) : null}
</div> </div>
) )
} }

View File

@ -1,24 +1,24 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
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, Loader2 } from "lucide-react"
import { OfficeEditorChrome } from "@/components/drive/office-editor-chrome"
import { RichTextDocumentEditor } from "@/components/drive/richtext-document" import { RichTextDocumentEditor } from "@/components/drive/richtext-document"
import { useDriveMutations, useDriveShares } from "@/lib/api/hooks/use-drive-queries" import { useDriveFileById, useDriveMutations, useDriveShares } from "@/lib/api/hooks/use-drive-queries"
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 { resolveRenameName } from "@/lib/drive/drive-default-name" import { resolveRenameName } from "@/lib/drive/drive-default-name"
import { driveFolderHref } from "@/lib/drive/drive-sidebar-tree" import { driveFolderHref } from "@/lib/drive/drive-sidebar-tree"
import { buildDriveEditHref, resolveDriveEditReturnTo } from "@/lib/drive/drive-url" import { resolveDriveEditReturnTo } from "@/lib/drive/drive-url"
import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title" import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store" import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
import { colorForGuestId } from "@/lib/drive/guest-editor-identity" import { colorForGuestId } from "@/lib/drive/guest-editor-identity"
import type { RichTextSaveStatus, RichTextSessionResponse } from "@/lib/drive/richtext-types" import type { RichTextSessionResponse } from "@/lib/drive/richtext-types"
import type { DriveFileInfo } from "@/lib/api/types"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity" import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { cn } from "@/lib/utils"
function fileNameFromPath(filePath: string): string { function fileNameFromPath(filePath: string): string {
return filePath.split("/").filter(Boolean).pop() ?? filePath return filePath.split("/").filter(Boolean).pop() ?? filePath
@ -30,57 +30,43 @@ function renameTargetPath(filePath: string, newName: string): string {
return `${base}/${newName}`.replace(/\/+/g, "/") || `/${newName}` return `${base}/${newName}`.replace(/\/+/g, "/") || `/${newName}`
} }
function saveStatusLabel(status: RichTextSaveStatus): string { export function RichTextEditor({ fileId }: { fileId: string }) {
switch (status) { const queryClient = useQueryClient()
case "saving":
return "Enregistrement…"
case "saved":
return "Enregistré"
case "error":
return "Erreur d'enregistrement"
default:
return ""
}
}
export function RichTextEditor({
filePath,
returnTo,
}: {
filePath: string
returnTo?: string | null
}) {
const router = useRouter()
const identity = useChromeIdentity() const identity = useChromeIdentity()
const setSharePath = useDriveUIStore((s) => s.setSharePath) const setSharePath = useDriveUIStore((s) => s.setSharePath)
const { data: file, error: fileError, isLoading: fileLoading } = useDriveFileById(fileId)
const displayPath = file?.path ?? ""
const [session, setSession] = useState<RichTextSessionResponse | null>(null) const [session, setSession] = useState<RichTextSessionResponse | null>(null)
const [error, setError] = useState<string | null>(null) const [sessionError, setSessionError] = useState<string | null>(null)
const [saveStatus, setSaveStatus] = useState<RichTextSaveStatus>("idle")
const [displayPath, setDisplayPath] = useState(filePath)
useEffect(() => { const fileName = file?.name ?? fileNameFromPath(displayPath)
setDisplayPath(filePath)
}, [filePath])
const fileName = fileNameFromPath(displayPath)
const title = displayFileBaseName(fileName) const title = displayFileBaseName(fileName)
useDriveDocumentTitle(title) useDriveDocumentTitle(title)
const sessionReturnTo = readDriveEditorReturnTo()
const backHref = useMemo( const backHref = useMemo(
() => () =>
resolveDriveEditReturnTo(returnTo, displayPath, (folderPath) => resolveDriveEditReturnTo(
driveFolderHref("files", folderPath) null,
displayPath,
(folderPath) => driveFolderHref("files", folderPath),
sessionReturnTo
), ),
[returnTo, displayPath] [displayPath, sessionReturnTo]
) )
const { data: sharesData } = useDriveShares(displayPath, Boolean(displayPath)) const { data: sharesData } = useDriveShares(displayPath, Boolean(displayPath))
const { rename } = useDriveMutations() const { rename } = useDriveMutations()
const refreshFile = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: ["drive", "file", fileId] })
}, [fileId, queryClient])
useEffect(() => { useEffect(() => {
if (!displayPath) return
let cancelled = false let cancelled = false
setSession(null) setSession(null)
setError(null) setSessionError(null)
void (async () => { void (async () => {
try { try {
const res = await apiClient.post<RichTextSessionResponse>("/richtext/session", { const res = await apiClient.post<RichTextSessionResponse>("/richtext/session", {
@ -89,7 +75,9 @@ export function RichTextEditor({
}) })
if (!cancelled) setSession(res) if (!cancelled) setSession(res)
} catch (e) { } catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Impossible d'ouvrir le document") if (!cancelled) {
setSessionError(e instanceof Error ? e.message : "Impossible d'ouvrir le document")
}
} }
})() })()
return () => { return () => {
@ -99,28 +87,85 @@ export function RichTextEditor({
const handleRename = useCallback( const handleRename = useCallback(
async (input: string) => { async (input: string) => {
if (!displayPath) return
const newName = resolveRenameName({ name: fileName, type: "file" }, input) const newName = resolveRenameName({ name: fileName, type: "file" }, input)
if (displayFileBaseName(fileName) === input.trim()) return if (displayFileBaseName(fileName) === input.trim()) return
await rename.mutateAsync({ path: displayPath, new_name: newName }) await rename.mutateAsync({ path: displayPath, new_name: newName })
const nextPath = renameTargetPath(displayPath, newName) await refreshFile()
setDisplayPath(nextPath)
router.replace(buildDriveEditHref(nextPath, returnTo ?? undefined, "richtext"))
}, },
[displayPath, fileName, rename, returnTo, router] [displayPath, fileName, refreshFile, rename]
) )
const openShare = useCallback(() => { const openShare = useCallback(() => {
setSharePath(displayPath) if (displayPath) setSharePath(displayPath)
}, [displayPath, setSharePath]) }, [displayPath, setSharePath])
const statusText = saveStatusLabel(saveStatus) const moveFile = useMemo((): DriveFileInfo | undefined => {
if (!file || !displayPath) return undefined
return {
...file,
path: displayPath,
name: fileName,
type: "file",
}
}, [displayPath, file, fileName])
const handleFileMoved = useCallback(
async (_newPath: string) => {
await refreshFile()
},
[refreshFile]
)
const collabUserName = identity?.name?.trim() || identity?.email || "Utilisateur" const collabUserName = identity?.name?.trim() || identity?.email || "Utilisateur"
const collabUserColor = colorForGuestId(identity?.email ?? collabUserName) const collabUserColor = colorForGuestId(identity?.email ?? collabUserName)
if (error) { const chrome = useMemo(
() => ({
title,
onRename: handleRename,
renameDisabled: rename.isPending,
backHref,
backLabel: "Drive",
showBack: true,
shares: sharesData?.shares ?? [],
onShareClick: openShare,
showShare: true,
showAccount: true,
moveFile,
onFileMoved: handleFileMoved,
}),
[
title,
handleRename,
rename.isPending,
backHref,
sharesData?.shares,
openShare,
moveFile,
handleFileMoved,
]
)
const error =
fileError instanceof Error
? fileError.message
: sessionError
if (fileLoading) {
return (
<div className="flex h-dvh items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (error || !file) {
return ( 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">{error}</p> <p className="text-sm text-muted-foreground">
{error ?? "Document introuvable"}
</p>
<Button variant="outline" asChild> <Button variant="outline" asChild>
<Link href={backHref}> <Link href={backHref}>
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
@ -132,31 +177,7 @@ export function RichTextEditor({
} }
return ( return (
<div className="flex h-full min-h-0 flex-col bg-background"> <div className="flex h-dvh min-h-0 flex-col">
<OfficeEditorChrome
backHref={backHref}
backLabel="Drive"
title={title}
onRename={handleRename}
renameDisabled={rename.isPending}
shares={sharesData?.shares ?? []}
onShareClick={openShare}
showShare
showAccount
trailing={
statusText ? (
<span
className={cn(
"text-xs text-muted-foreground",
saveStatus === "error" && "text-destructive"
)}
>
{statusText}
</span>
) : null
}
/>
<div className="min-h-0 flex-1">
{!session ? ( {!session ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
@ -167,10 +188,9 @@ export function RichTextEditor({
mode="edit" mode="edit"
userName={collabUserName} userName={collabUserName}
userColor={collabUserColor} userColor={collabUserColor}
onSaveStatus={setSaveStatus} chrome={chrome}
/> />
)} )}
</div> </div>
</div>
) )
} }

View File

@ -0,0 +1,61 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { Loader2 } from "lucide-react"
import { apiClient } from "@/lib/api/client"
import type { DriveFileInfo } from "@/lib/api/types"
import { stashDriveEditorReturnTo } from "@/lib/drive/drive-editor-return"
import { buildDriveDocsEditHref } from "@/lib/drive/drive-url"
/** Redirect path-based /drive/edit URLs to stable id-based /drive/docs URLs. */
export function RichTextEditorLegacyRedirect({
filePath,
returnTo,
}: {
filePath: string
returnTo?: string | null
}) {
const router = useRouter()
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (returnTo) stashDriveEditorReturnTo(returnTo)
else stashDriveEditorReturnTo()
let cancelled = false
void (async () => {
try {
const info = await apiClient.get<DriveFileInfo>(
`/drive/files/info${filePath.startsWith("/") ? filePath : `/${filePath}`}`
)
if (cancelled) return
if (!info.file_id) {
setError("Identifiant du document introuvable")
return
}
router.replace(buildDriveDocsEditHref(info.file_id))
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : "Impossible d'ouvrir le document")
}
}
})()
return () => {
cancelled = true
}
}, [filePath, returnTo, router])
if (error) {
return (
<div className="flex h-dvh items-center justify-center p-8 text-sm text-muted-foreground">
{error}
</div>
)
}
return (
<div className="flex h-dvh items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}

View File

@ -0,0 +1,67 @@
"use client"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { senderInitial } from "@/lib/sender-display"
import type { CollabPresenceUser } from "@/lib/drive/use-collab-presence"
import { cn } from "@/lib/utils"
const MAX_VISIBLE = 5
export function CollabPresenceAvatars({
users,
className,
}: {
users: CollabPresenceUser[]
className?: string
}) {
if (users.length === 0) return null
const visible = users.slice(0, MAX_VISIBLE)
const overflow = users.length - visible.length
return (
<TooltipProvider delayDuration={200}>
<div className={cn("flex items-center -space-x-1.5", className)}>
{visible.map((user) => (
<Tooltip key={user.clientId}>
<TooltipTrigger asChild>
<div
className={cn(
"flex size-8 items-center justify-center rounded-full border-2 border-white text-xs font-semibold text-white shadow-sm dark:border-[#202124]",
user.isLocal && "ring-2 ring-[#1967d2]/40 ring-offset-1 ring-offset-white dark:ring-offset-[#202124]"
)}
style={{ backgroundColor: user.color }}
aria-label={user.isLocal ? `${user.name} (vous)` : user.name}
>
{senderInitial(user.name)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{user.isLocal ? `${user.name} (vous)` : user.name}
</TooltipContent>
</Tooltip>
))}
{overflow > 0 ? (
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex size-8 items-center justify-center rounded-full border-2 border-white bg-[#5f6368] text-xs font-semibold text-white shadow-sm dark:border-[#202124]"
aria-label={`${overflow} autre(s) utilisateur(s)`}
>
+{overflow}
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{users.slice(MAX_VISIBLE).map((u) => u.name).join(", ")}
</TooltipContent>
</Tooltip>
) : null}
</div>
</TooltipProvider>
)
}

View File

@ -0,0 +1,185 @@
"use client"
import type { ReactNode } from "react"
import Link from "next/link"
import { Globe, Lock, Star, 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 { DocsLogoIcon } from "@/components/drive/richtext/docs-logo-icon"
import { DocsMenubar } from "@/components/drive/richtext/docs-menubar"
import { DocsMoveButton } from "@/components/drive/richtext/docs-move-button"
import { Button } from "@/components/ui/button"
import type { DriveShare, DriveFileInfo } 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 { PageFormatId } from "@/lib/drive/page-formats"
import type { RichTextSaveStatus } from "@/lib/drive/richtext-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: RichTextSaveStatus): string {
switch (status) {
case "saving":
return "Enregistrement…"
case "saved":
return "Enregistré dans UltiDrive"
case "error":
return "Erreur d'enregistrement"
default:
return ""
}
}
export function DocsChrome({
title,
onRename,
renameDisabled,
backHref,
backLabel,
showBack = true,
shares = [],
onShareClick,
showShare = false,
showAccount = false,
saveStatus = "idle",
presenceUsers = [],
pageFormatId,
onPageFormatChange,
zoom,
onZoomChange,
trailing,
moveFile,
onFileMoved,
}: {
title: string
onRename?: (next: string) => Promise<void>
renameDisabled?: boolean
backHref?: string
backLabel?: string
showBack?: boolean
shares?: DriveShare[]
onShareClick?: () => void
showShare?: boolean
showAccount?: boolean
saveStatus?: RichTextSaveStatus
presenceUsers?: CollabPresenceUser[]
pageFormatId: PageFormatId
onPageFormatChange: (id: PageFormatId) => void
zoom: number
onZoomChange: (zoom: number) => void
trailing?: ReactNode
/** Propriétaire uniquement — affiche le bouton déplacer. */
moveFile?: DriveFileInfo
onFileMoved?: (newPath: string) => void
}) {
const shareIcon = resolveShareButtonIcon(shares)
const statusText = saveStatusLabel(saveStatus)
const iconHref = showBack !== false && backHref ? backHref : "/drive"
const iconLabel =
showBack !== false && backHref
? (backLabel ?? "Retour au Drive")
: "Ouvrir le Drive"
return (
<>
<header className="shrink-0 bg-white dark:bg-background">
<div className="flex min-h-[72px] items-center gap-0 px-2 py-1">
<Button
variant="ghost"
size="icon"
className="mr-1 size-11 shrink-0 self-center rounded-full hover:bg-[#e8eaed] dark:hover:bg-muted"
asChild
>
<Link href={iconHref} aria-label={iconLabel}>
<DocsLogoIcon className="size-[38px]" />
</Link>
</Button>
<div className="min-w-0 flex-1 self-center">
<div className="docs-chrome-title-row flex min-w-0 items-center gap-0 leading-tight">
{onRename ? (
<OfficeEditorInlineTitle
value={title}
onRename={onRename}
disabled={renameDisabled}
className="pr-1 text-base font-normal"
/>
) : (
<span className="block truncate pl-2 pr-1 text-base font-normal">{title}</span>
)}
<div className="flex shrink-0 items-center -space-x-0.5">
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0 text-muted-foreground"
disabled
aria-label="Ajouter aux favoris (bientôt)"
>
<Star className="size-4" />
</Button>
{moveFile && onFileMoved ? (
<DocsMoveButton file={moveFile} onFileMoved={onFileMoved} />
) : null}
</div>
{statusText ? (
<span
className={cn(
"ml-1.5 hidden min-w-0 line-clamp-1 overflow-hidden break-all text-ellipsis text-xs text-[#5f6368] sm:inline dark:text-muted-foreground",
saveStatus === "error" && "text-destructive"
)}
>
{statusText}
</span>
) : null}
</div>
<div className="-mt-1 flex min-w-0 items-center overflow-hidden">
<DocsMenubar
className="docs-menubar shrink-0"
pageFormatId={pageFormatId}
onPageFormatChange={onPageFormatChange}
zoom={zoom}
onZoomChange={onZoomChange}
/>
</div>
</div>
<div className="flex shrink-0 items-center gap-2 self-center pl-2">
{trailing}
{presenceUsers.length > 0 ? (
<CollabPresenceAvatars users={presenceUsers} />
) : null}
{showShare ? (
<Button
type="button"
size="sm"
className={cn(
"gap-2 rounded-full border-0 px-5 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>
</header>
{showShare ? <ShareDialog /> : null}
</>
)
}

View File

@ -0,0 +1,31 @@
import { cn } from "@/lib/utils"
/** material-symbols:description (Iconify MCP) — corps sans le pli. */
const BODY =
"M8 18h8v-2H8zm0-4h8v-2H8zm-2 8q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h8l6 6v12q0 .825-.587 1.413T18 22z"
const FOLD = "M13 7h5l-5-5z"
const LINE_1 = "M8 18h8v-2H8z"
const LINE_2 = "M8 14h8v-2H8z"
/** Décalage vertical du pli blanc (viewBox 24). */
const FOLD_Y_OFFSET = 1
export function DocsLogoIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className={cn("shrink-0", className)}
aria-hidden
>
<path fill="#4285F4" d={BODY} />
<path fill="#ffffff" d={LINE_1} />
<path fill="#ffffff" d={LINE_2} />
<path
fill="#ffffff"
d={FOLD}
transform={`translate(0 ${FOLD_Y_OFFSET})`}
/>
</svg>
)
}

View File

@ -0,0 +1,100 @@
"use client"
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarTrigger,
} from "@/components/ui/menubar"
import { PAGE_FORMATS, type PageFormatId } from "@/lib/drive/page-formats"
import { cn } from "@/lib/utils"
const MENU_LABELS = [
"Fichier",
"Édition",
"Affichage",
"Insertion",
"Format",
"Outils",
"Aide",
] as const
export function DocsMenubar({
pageFormatId,
onPageFormatChange,
zoom,
onZoomChange,
className,
}: {
pageFormatId: PageFormatId
onPageFormatChange: (id: PageFormatId) => void
zoom: number
onZoomChange: (zoom: number) => void
className?: string
}) {
return (
<Menubar
className={cn(
"docs-menubar h-auto gap-0 border-0 bg-transparent p-0 shadow-none",
className
)}
>
{MENU_LABELS.map((label) => {
if (label === "Affichage") {
return (
<MenubarMenu key={label}>
<MenubarTrigger className="docs-menu-trigger">{label}</MenubarTrigger>
<MenubarContent align="start" className="min-w-52">
<MenubarItem disabled className="text-xs text-muted-foreground">
Mode (bientôt)
</MenubarItem>
<MenubarSeparator />
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Taille de page
</div>
{PAGE_FORMATS.map((format) => (
<MenubarItem
key={format.id}
onClick={() => onPageFormatChange(format.id)}
className={cn(pageFormatId === format.id && "bg-accent")}
>
{format.label}
<span className="ml-auto text-xs text-muted-foreground">
{format.widthMm} × {format.heightMm} mm
</span>
</MenubarItem>
))}
<MenubarSeparator />
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Zoom
</div>
{[50, 75, 100, 125, 150, 200].map((value) => (
<MenubarItem
key={value}
onClick={() => onZoomChange(value)}
className={cn(zoom === value && "bg-accent")}
>
{value}%
</MenubarItem>
))}
</MenubarContent>
</MenubarMenu>
)
}
return (
<MenubarMenu key={label}>
<MenubarTrigger className="docs-menu-trigger">{label}</MenubarTrigger>
<MenubarContent align="start">
<MenubarItem disabled className="text-muted-foreground">
Bientôt disponible
</MenubarItem>
</MenubarContent>
</MenubarMenu>
)
})}
</Menubar>
)
}

View File

@ -0,0 +1,45 @@
"use client"
import { useState } from "react"
import { FolderInput } from "lucide-react"
import { Button } from "@/components/ui/button"
import { DriveMoveDialog } from "@/components/drive/drive-move-dialog"
import type { DriveFileInfo } from "@/lib/api/types"
import { buildMoveDestination } from "@/lib/drive/drive-move-items"
export function DocsMoveButton({
file,
onFileMoved,
}: {
file: DriveFileInfo
onFileMoved: (newPath: string) => void
}) {
const [moveOpen, setMoveOpen] = useState(false)
return (
<>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0 text-muted-foreground"
aria-label="Déplacer le document"
title="Déplacer le document"
onClick={() => setMoveOpen(true)}
>
<FolderInput className="size-4" />
</Button>
<DriveMoveDialog
open={moveOpen}
onOpenChange={setMoveOpen}
sources={[file]}
mode="move"
onMoved={(destinationFolder) => {
if (!destinationFolder) return
onFileMoved(buildMoveDestination(destinationFolder, file.name))
}}
/>
</>
)
}

View File

@ -0,0 +1,214 @@
"use client"
import { memo, useEffect, useRef, useState } from "react"
import { EditorContent, type Editor } from "@tiptap/react"
import {
getPageFormat,
PAGE_MARGIN_PX,
pageFormatHeightPx,
pageFormatWidthPx,
type PageFormatId,
} from "@/lib/drive/page-formats"
import { cn } from "@/lib/utils"
import { focusEditorAtPointer } from "@/lib/drive/focus-editor-at-pointer"
const PAGE_GAP_PX = 12
/** Actual block layout height — ignores CSS min-height on ProseMirror (page stack). */
function measureProseContentHeight(prose: HTMLElement): number {
if (prose.childElementCount === 0) {
return 0
}
let maxBottom = 0
for (const child of prose.children) {
const el = child as HTMLElement
maxBottom = Math.max(maxBottom, el.offsetTop + el.offsetHeight)
}
return maxBottom
}
function DocsPageViewInner({
editor,
pageFormatId,
zoom,
editable,
onPageCountChange,
}: {
editor: Editor
pageFormatId: PageFormatId
zoom: number
editable: boolean
onPageCountChange?: (count: number) => void
}) {
const format = getPageFormat(pageFormatId)
const pageWidth = pageFormatWidthPx(format)
const pageHeight = pageFormatHeightPx(format)
const canvasRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const [pageCount, setPageCount] = useState(1)
const [narrowViewport, setNarrowViewport] = useState(false)
const onPageCountChangeRef = useRef(onPageCountChange)
onPageCountChangeRef.current = onPageCountChange
const scale = zoom / 100
const scaledWidth = pageWidth * scale
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const syncViewport = () => {
setNarrowViewport(canvas.clientWidth < scaledWidth)
}
syncViewport()
const ro = new ResizeObserver(syncViewport)
ro.observe(canvas)
return () => ro.disconnect()
}, [scaledWidth])
useEffect(() => {
const surface = contentRef.current
if (!surface) return
let rafId = 0
const measure = () => {
const prose = surface.querySelector(".ProseMirror") as HTMLElement | null
if (!prose) return
const contentHeight = measureProseContentHeight(prose)
const paddedHeight = PAGE_MARGIN_PX * 2 + contentHeight
const count = Math.max(1, Math.ceil(paddedHeight / pageHeight))
setPageCount((prev) => {
if (prev === count) return prev
onPageCountChangeRef.current?.(count)
return count
})
}
const scheduleMeasure = () => {
if (rafId) cancelAnimationFrame(rafId)
rafId = requestAnimationFrame(measure)
}
scheduleMeasure()
const prose = surface.querySelector(".ProseMirror") as HTMLElement | null
const ro = prose ? new ResizeObserver(scheduleMeasure) : null
if (prose && ro) ro.observe(prose)
const onTransaction = () => scheduleMeasure()
editor.on("transaction", onTransaction)
return () => {
if (rafId) cancelAnimationFrame(rafId)
ro?.disconnect()
editor.off("transaction", onTransaction)
}
}, [pageHeight, editor])
const stackHeight = pageCount * pageHeight + (pageCount - 1) * PAGE_GAP_PX
const proseMinHeight = stackHeight - PAGE_MARGIN_PX * 2
const scaledHeight = stackHeight * scale
const verticalPadding = narrowViewport ? 32 : 64
return (
<div
ref={canvasRef}
className="ultidrive-docs-canvas min-h-0 flex-1 overflow-auto bg-[#f9fbfd] dark:bg-[#202124]"
>
<div
className={cn("mx-auto", narrowViewport ? "pb-8 pt-0" : "py-8")}
style={{
width: scaledWidth,
minHeight: scaledHeight + verticalPadding,
}}
>
<div
className="relative mx-auto"
style={{
width: scaledWidth,
height: scaledHeight,
}}
>
<div
className="absolute left-1/2 top-0 origin-top -translate-x-1/2"
style={{
width: pageWidth,
height: stackHeight,
transform: `scale(${scale})`,
}}
>
{Array.from({ length: pageCount }, (_, index) => (
<div
key={index}
className="ultidrive-docs-page absolute left-0 bg-white dark:bg-white"
style={{
top: index * (pageHeight + PAGE_GAP_PX),
width: pageWidth,
height: pageHeight,
boxShadow:
"0 1px 3px 1px rgba(60,64,67,.15), 0 1px 2px 0 rgba(60,64,67,.3)",
}}
aria-hidden
/>
))}
<div
ref={contentRef}
className="ultidrive-docs-editor-surface relative z-10"
style={{
padding: PAGE_MARGIN_PX,
minHeight: stackHeight,
["--docs-prose-min-height" as string]: `${Math.max(
pageHeight - PAGE_MARGIN_PX * 2,
proseMinHeight
)}px`,
}}
onMouseDown={(event) => {
if (!editable) return
const target = event.target as HTMLElement
if (target.closest(".ProseMirror")) return
event.preventDefault()
focusEditorAtPointer(editor, event.clientX, event.clientY)
}}
>
<EditorContent
editor={editor}
className={cn(!editable && "pointer-events-none select-text")}
/>
</div>
</div>
</div>
</div>
</div>
)
}
export const DocsPageView = memo(DocsPageViewInner)
export function DocsStatusBar({
pageFormatId,
pageCount,
className,
}: {
pageFormatId: PageFormatId
pageCount: number
className?: string
}) {
const format = getPageFormat(pageFormatId)
return (
<div
className={cn(
"flex shrink-0 items-center justify-between border-t border-[#dadce0] bg-[#edf2fa] px-4 py-1 text-xs text-[#5f6368] dark:border-border dark:bg-muted/40 dark:text-muted-foreground",
className
)}
>
<span>Page 1 sur {pageCount}</span>
<span>
{format.label} ({format.widthMm} × {format.heightMm} mm)
</span>
</div>
)
}

View File

@ -0,0 +1,923 @@
"use client"
import { memo, useCallback, useMemo, useRef, useState } from "react"
import { Icon } from "@iconify/react"
import type { Editor } from "@tiptap/react"
import {
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
ChevronDown,
ChevronUp,
Bold,
Image as ImageIcon,
Italic,
Link2,
List,
ListOrdered,
Minus,
MoreHorizontal,
Plus,
Printer,
Redo,
Underline as UnderlineIcon,
Undo,
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useToolbarOverflow } from "@/lib/drive/use-toolbar-overflow"
import {
applyFontSizePx,
DOCS_FONT_SIZES,
stepFontSizePx,
} from "@/lib/drive/docs-font-size"
import {
applyFontFamily,
DOCS_FONT_FAMILIES,
type DocsFontFamilyName,
} from "@/lib/drive/docs-font-family"
import { useDocsToolbarState } from "@/lib/drive/use-docs-toolbar-state"
import { cn } from "@/lib/utils"
const TEXT_STYLES = [
{ id: "paragraph", label: "Texte normal" },
{ id: "heading1", label: "Titre 1" },
{ id: "heading2", label: "Titre 2" },
{ id: "heading3", label: "Titre 3" },
{ id: "heading4", label: "Titre 4" },
] as const
const TEXT_COLORS = [
"#000000",
"#434343",
"#666666",
"#999999",
"#b7b7b7",
"#cccccc",
"#d9d9d9",
"#efefef",
"#f3f3f3",
"#ffffff",
"#980000",
"#ff0000",
"#ff9900",
"#ffff00",
"#00ff00",
"#00ffff",
"#4a86e8",
"#0000ff",
"#9900ff",
"#ff00ff",
] as const
/** Classic highlighter + pastel palette (Google Docs / marker style). */
const HIGHLIGHT_COLORS = [
"#ffff00",
"#fff475",
"#fce8b2",
"#f4cccc",
"#ffc8dd",
"#d9ead3",
"#b6d7a8",
"#cfe2f3",
"#a4c2f4",
"#d9d2e9",
"#e6e6e6",
] as const
const ZOOM_OPTIONS = [50, 75, 90, 100, 125, 150, 200] as const
function applyTextStyle(editor: Editor, styleId: string) {
if (styleId === "paragraph") {
editor.chain().focus().setParagraph().run()
return
}
const level = Number(styleId.replace("heading", "")) as 1 | 2 | 3 | 4
editor.chain().focus().setHeading({ level }).run()
}
function DocsToolbarInner({
editor,
disabled,
zoom,
onZoomChange,
spellcheck,
onToggleSpellcheck,
showChromeToggle,
chromeCollapsed,
onToggleChromeCollapsed,
}: {
editor: Editor | null
disabled?: boolean
zoom: number
onZoomChange: (zoom: number) => void
spellcheck: boolean
onToggleSpellcheck: () => void
showChromeToggle?: boolean
chromeCollapsed?: boolean
onToggleChromeCollapsed?: () => void
}) {
const imageInputRef = useRef<HTMLInputElement>(null)
const [linkOpen, setLinkOpen] = useState(false)
const [linkUrl, setLinkUrl] = useState("")
const [lastHighlightColor, setLastHighlightColor] = useState<string>(HIGHLIGHT_COLORS[0]!)
const toolbarState = useDocsToolbarState(editor)
const insertImage = useCallback(
(file: File) => {
if (!editor) return
const reader = new FileReader()
reader.onload = () => {
const src = reader.result as string
editor.chain().focus().setImage({ src }).run()
}
reader.readAsDataURL(file)
},
[editor]
)
const applyLink = useCallback(() => {
if (!editor) return
const url = linkUrl.trim()
if (!url) {
editor.chain().focus().unsetLink().run()
} else {
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run()
}
setLinkOpen(false)
setLinkUrl("")
}, [editor, linkUrl])
const segments = useMemo(() => {
if (!editor || !toolbarState) return []
const {
canUndo,
canRedo,
styleId,
fontFamilyState,
fontSizeState,
canStepFontSizeDown,
canStepFontSizeUp,
textColor,
highlightColor,
isBold,
isItalic,
isUnderline,
isLink,
alignLeft,
alignCenter,
alignRight,
alignJustify,
isBulletList,
isOrderedList,
} = toolbarState
return [
{
id: "history",
sepAfter: false,
node: (
<>
<ToolbarIconBtn
disabled={disabled || !canUndo}
onClick={() => editor.chain().focus().undo().run()}
label="Annuler"
>
<Undo className="size-4" />
</ToolbarIconBtn>
<ToolbarIconBtn
disabled={disabled || !canRedo}
onClick={() => editor.chain().focus().redo().run()}
label="Rétablir"
>
<Redo className="size-4" />
</ToolbarIconBtn>
</>
),
},
{
id: "print",
sepAfter: false,
node: (
<>
<ToolbarIconBtn label="Imprimer" onClick={() => window.print()}>
<Printer className="size-4" />
</ToolbarIconBtn>
<ToolbarIconBtn
disabled={disabled}
active={spellcheck}
label={
spellcheck
? "Désactiver la vérification orthographique"
: "Activer la vérification orthographique"
}
onClick={onToggleSpellcheck}
>
<Icon icon="material-symbols:spellcheck" className="size-4" />
</ToolbarIconBtn>
<ToolbarIconBtn disabled label="Reproduire la mise en forme (bientôt)">
<Icon icon="material-symbols:format-paint-outline" className="size-4" />
</ToolbarIconBtn>
</>
),
},
{
id: "zoom",
sepAfter: true,
node: (
<Select
value={String(zoom)}
onValueChange={(v) => onZoomChange(Number(v))}
disabled={disabled}
>
<SelectTrigger className="docs-toolbar-select h-7 w-[72px] shrink-0 border-0 bg-transparent px-1 shadow-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ZOOM_OPTIONS.map((z) => (
<SelectItem key={z} value={String(z)}>
{z}%
</SelectItem>
))}
</SelectContent>
</Select>
),
},
{
id: "style",
sepAfter: true,
node: (
<Select
value={styleId}
onValueChange={(v) => applyTextStyle(editor, v)}
disabled={disabled}
>
<SelectTrigger className="docs-toolbar-select h-7 w-[120px] shrink-0 border-0 bg-transparent px-1 shadow-none">
<SelectValue />
</SelectTrigger>
<SelectContent className="docs-toolbar-select-content docs-toolbar-select-content--style">
{TEXT_STYLES.map((s) => (
<SelectItem
key={s.id}
value={s.id}
className="docs-toolbar-style-item"
>
<span
className={cn(
"docs-toolbar-style-preview",
`docs-toolbar-style-preview--${s.id}`
)}
>
{s.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
),
},
{
id: "font-family",
sepAfter: true,
node: (
<Select
disabled={disabled}
value={fontFamilyState.kind === "single" ? fontFamilyState.name : undefined}
onValueChange={(value) => applyFontFamily(editor, value as DocsFontFamilyName)}
>
<SelectTrigger
className="docs-toolbar-select h-7 w-[108px] shrink-0 border-0 bg-transparent px-1 shadow-none"
style={
fontFamilyState.kind === "single"
? {
fontFamily: DOCS_FONT_FAMILIES.find(
(f) => f.name === fontFamilyState.name
)?.stack,
}
: undefined
}
>
<SelectValue placeholder="Police" />
</SelectTrigger>
<SelectContent className="docs-toolbar-select-content docs-toolbar-select-content--font">
{DOCS_FONT_FAMILIES.map((f) => (
<SelectItem key={f.name} value={f.name}>
<span
className="docs-toolbar-font-preview"
style={{ fontFamily: f.stack }}
>
{f.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
),
},
{
id: "font-size",
sepAfter: true,
node: (
<div className="docs-toolbar-font-size flex shrink-0 items-center">
<ToolbarIconBtn
disabled={disabled || !canStepFontSizeDown}
label="Diminuer la taille"
className="docs-toolbar-btn--size-step"
onClick={() => stepFontSizePx(editor, -1)}
>
<Minus className="size-3.5" />
</ToolbarIconBtn>
<Select
disabled={disabled}
value={fontSizeState.kind === "single" ? String(fontSizeState.size) : undefined}
onValueChange={(value) => applyFontSizePx(editor, Number(value))}
>
<SelectTrigger className="docs-toolbar-select docs-toolbar-select--size shrink-0 bg-transparent shadow-none">
<SelectValue placeholder="" />
</SelectTrigger>
<SelectContent>
{DOCS_FONT_SIZES.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<ToolbarIconBtn
disabled={disabled || !canStepFontSizeUp}
label="Augmenter la taille"
className="docs-toolbar-btn--size-step"
onClick={() => stepFontSizePx(editor, 1)}
>
<Plus className="size-3.5" />
</ToolbarIconBtn>
</div>
),
},
{
id: "marks-basic",
sepAfter: false,
node: (
<>
<ToolbarIconBtn
disabled={disabled}
active={isBold}
onClick={() => editor.chain().focus().toggleMark("bold").run()}
label="Gras"
>
<Bold className="size-4" />
</ToolbarIconBtn>
<ToolbarIconBtn
disabled={disabled}
active={isItalic}
onClick={() => editor.chain().focus().toggleMark("italic").run()}
label="Italique"
>
<Italic className="size-4" />
</ToolbarIconBtn>
<ToolbarIconBtn
disabled={disabled}
active={isUnderline}
onClick={() => editor.chain().focus().toggleUnderline().run()}
label="Souligné"
>
<UnderlineIcon className="size-4" />
</ToolbarIconBtn>
<ColorPicker
disabled={disabled}
label="Couleur du texte"
colors={TEXT_COLORS}
currentColor={textColor}
onPick={(color) => editor.chain().focus().setColor(color).run()}
icon="material-symbols:format-color-text"
/>
</>
),
},
{
id: "highlight",
sepAfter: true,
node: (
<HighlightColorPicker
disabled={disabled}
colors={HIGHLIGHT_COLORS}
currentColor={highlightColor ?? lastHighlightColor}
isActive={highlightColor != null}
onPick={(color) => {
setLastHighlightColor(color)
editor.chain().focus().setHighlight({ color }).run()
}}
onClear={() => editor.chain().focus().unsetHighlight().run()}
/>
),
},
{
id: "insert-link",
sepAfter: false,
node: (
<>
<Popover open={linkOpen} onOpenChange={setLinkOpen}>
<PopoverTrigger asChild>
<ToolbarIconBtn
disabled={disabled}
active={isLink}
label="Lien"
onClick={() => {
const prev = editor.getAttributes("link").href as string | undefined
setLinkUrl(prev ?? "")
}}
>
<Link2 className="size-4" />
</ToolbarIconBtn>
</PopoverTrigger>
<PopoverContent className="w-72 p-2" align="start">
<div className="flex gap-2">
<input
type="url"
value={linkUrl}
placeholder="https://"
className="h-8 min-w-0 flex-1 rounded-md border border-input bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") applyLink()
}}
/>
<Button type="button" size="sm" onClick={applyLink}>
OK
</Button>
</div>
</PopoverContent>
</Popover>
<ToolbarIconBtn disabled label="Commentaire (bientôt)">
<Icon icon="material-symbols:add-comment-outline" className="size-4" />
</ToolbarIconBtn>
</>
),
},
{
id: "insert-image",
sepAfter: true,
node: (
<ToolbarIconBtn
disabled={disabled}
label="Insérer une image"
onClick={() => imageInputRef.current?.click()}
>
<ImageIcon className="size-4" />
</ToolbarIconBtn>
),
},
{
id: "align",
sepAfter: false,
node: (
<>
<ToolbarIconBtn
disabled={disabled}
active={alignLeft}
onClick={() => editor.chain().focus().setTextAlign("left").run()}
label="Aligner à gauche"
>
<AlignLeft className="size-4" />
</ToolbarIconBtn>
<ToolbarIconBtn
disabled={disabled}
active={alignCenter}
onClick={() => editor.chain().focus().setTextAlign("center").run()}
label="Centrer"
>
<AlignCenter className="size-4" />
</ToolbarIconBtn>
<ToolbarIconBtn
disabled={disabled}
active={alignRight}
onClick={() => editor.chain().focus().setTextAlign("right").run()}
label="Aligner à droite"
>
<AlignRight className="size-4" />
</ToolbarIconBtn>
<ToolbarIconBtn
disabled={disabled}
active={alignJustify}
onClick={() => editor.chain().focus().setTextAlign("justify").run()}
label="Justifier"
>
<AlignJustify className="size-4" />
</ToolbarIconBtn>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="docs-toolbar-btn size-7 shrink-0"
disabled={disabled}
aria-label="Interligne et espacement"
>
<Icon icon="material-symbols:format-line-spacing" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem disabled>Bientôt disponible</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
),
},
{
id: "lists",
sepAfter: false,
node: (
<>
<ToolbarIconBtn
disabled={disabled}
active={isBulletList}
onClick={() => editor.chain().focus().toggleBulletList().run()}
label="Liste à puces"
>
<List className="size-4" />
</ToolbarIconBtn>
<ToolbarIconBtn
disabled={disabled}
active={isOrderedList}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
label="Liste numérotée"
>
<ListOrdered className="size-4" />
</ToolbarIconBtn>
<ToolbarIconBtn disabled label="Liste de contrôle (bientôt)">
<Icon icon="material-symbols:checklist" className="size-4" />
</ToolbarIconBtn>
<ToolbarIconBtn disabled label="Diminuer le retrait (bientôt)">
<Icon icon="material-symbols:format-indent-decrease" className="size-4" />
</ToolbarIconBtn>
<ToolbarIconBtn disabled label="Augmenter le retrait (bientôt)">
<Icon icon="material-symbols:format-indent-increase" className="size-4" />
</ToolbarIconBtn>
</>
),
},
{
id: "clear",
sepAfter: false,
node: (
<ToolbarIconBtn
disabled={disabled}
label="Effacer la mise en forme"
onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}
>
<Icon icon="material-symbols:format-clear" className="size-4" />
</ToolbarIconBtn>
),
},
]
}, [
editor,
toolbarState,
disabled,
zoom,
onZoomChange,
spellcheck,
onToggleSpellcheck,
linkOpen,
linkUrl,
applyLink,
lastHighlightColor,
])
const { containerRef, measureRef, visibleCount, hasOverflow } = useToolbarOverflow(
segments.length
)
if (!editor) return null
const visibleSegments = segments.slice(0, visibleCount)
const overflowSegments = segments.slice(visibleCount)
return (
<div
className={cn(
"docs-toolbar-shell shrink-0",
chromeCollapsed && "docs-toolbar-shell--collapsed"
)}
>
<div
ref={containerRef}
className="docs-toolbar relative flex items-center gap-0 overflow-hidden px-1.5 py-0.5"
>
<div
ref={measureRef}
className="pointer-events-none invisible absolute left-0 top-0 flex h-0 overflow-hidden whitespace-nowrap"
aria-hidden
>
{segments.map((segment) => (
<div key={segment.id} className="flex shrink-0 items-center">
<div className="flex shrink-0 items-center gap-0">{segment.node}</div>
{segment.sepAfter ? <ToolbarSep /> : null}
</div>
))}
</div>
<div className="flex min-w-0 flex-1 items-center overflow-hidden">
{visibleSegments.map((segment) => (
<div key={segment.id} className="flex shrink-0 items-center">
<div className="flex shrink-0 items-center gap-0">{segment.node}</div>
{segment.sepAfter ? <ToolbarSep /> : null}
</div>
))}
</div>
{hasOverflow ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="docs-toolbar-btn size-7 shrink-0"
aria-label="Plus d'actions"
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-[min(70vh,480px)] w-auto overflow-y-auto p-2">
<div className="flex flex-col gap-1">
{overflowSegments.map((segment, index) => (
<div
key={segment.id}
className="flex flex-wrap items-center gap-0.5 border-t border-border pt-1 first:border-t-0 first:pt-0"
>
{index > 0 ? null : null}
{segment.node}
</div>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
) : null}
{showChromeToggle ? (
<>
<ToolbarSep />
<ToolbarIconBtn
active={chromeCollapsed}
label={
chromeCollapsed
? "Afficher l'en-tête du document"
: "Masquer l'en-tête du document"
}
onClick={onToggleChromeCollapsed}
>
{chromeCollapsed ? (
<ChevronDown className="size-4" />
) : (
<ChevronUp className="size-4" />
)}
</ToolbarIconBtn>
</>
) : null}
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) insertImage(file)
e.target.value = ""
}}
/>
</div>
</div>
)
}
export const DocsToolbar = memo(DocsToolbarInner)
function ToolbarSep() {
return <span aria-hidden className="docs-toolbar-sep" />
}
function ToolbarIconBtn({
ref,
children,
onClick,
active,
disabled,
label,
className,
...rest
}: {
children: React.ReactNode
onClick?: React.MouseEventHandler<HTMLButtonElement>
active?: boolean
disabled?: boolean
label: string
className?: string
} & React.ComponentPropsWithoutRef<"button"> & {
ref?: React.Ref<HTMLButtonElement>
}) {
return (
<Button
ref={ref}
type="button"
variant="ghost"
size="icon"
className={cn(
"docs-toolbar-btn size-7 shrink-0",
active && "docs-toolbar-btn--active",
className
)}
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
aria-pressed={active}
{...rest}
>
{children}
</Button>
)
}
function colorSwatchOutlineClass(hex: string): string {
const luminance = colorRelativeLuminance(hex)
if (luminance > 0.72) {
return "border border-black/50 ring-1 ring-black/30"
}
if (luminance > 0.45) {
return "border border-black/40 ring-1 ring-black/20"
}
return "border border-white/60 ring-1 ring-black/35"
}
function colorRelativeLuminance(hex: string): number {
const raw = hex.replace("#", "")
if (raw.length !== 6) return 0
const channel = (index: number) => {
const value = parseInt(raw.slice(index, index + 2), 16) / 255
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
}
return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4)
}
function ColorToolbarGlyph({
icon,
color,
highlight = false,
}: {
icon: string
color: string
highlight?: boolean
}) {
return (
<span
className={cn("docs-toolbar-color-glyph", highlight && "docs-toolbar-color-glyph--highlight")}
aria-hidden
>
<Icon icon={icon} className="docs-toolbar-color-glyph__icon docs-toolbar-icon" />
<span className="docs-toolbar-color-glyph__swatch" style={{ backgroundColor: color }} />
</span>
)
}
function ColorPicker({
disabled,
label,
colors,
currentColor,
onPick,
icon,
}: {
disabled?: boolean
label: string
colors: readonly string[]
currentColor: string
onPick: (color: string) => void
icon: string
}) {
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="docs-toolbar-btn size-7 shrink-0 px-1"
disabled={disabled}
aria-label={label}
title={label}
>
<ColorToolbarGlyph icon={icon} color={currentColor} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<div className="grid grid-cols-5 gap-1">
{colors.map((color) => (
<button
key={color}
type="button"
className={cn(
"size-6 rounded-sm border border-border",
currentColor.toLowerCase() === color.toLowerCase() && "ring-2 ring-[#1967d2]"
)}
style={{ backgroundColor: color }}
aria-label={color}
onClick={() => onPick(color)}
/>
))}
</div>
</PopoverContent>
</Popover>
)
}
function HighlightColorPicker({
disabled,
colors,
currentColor,
isActive,
onPick,
onClear,
}: {
disabled?: boolean
colors: readonly string[]
currentColor: string
isActive: boolean
onPick: (color: string) => void
onClear: () => void
}) {
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="docs-toolbar-btn size-7 shrink-0 px-1"
disabled={disabled}
aria-label="Couleur de surlignage"
title="Couleur de surlignage"
>
<ColorToolbarGlyph
icon="material-symbols:format-ink-highlighter"
color={currentColor}
highlight
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<div className="grid grid-cols-6 gap-1">
<button
type="button"
className={cn(
"flex size-6 items-center justify-center rounded-sm border border-border bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground",
!isActive && "ring-2 ring-[#1967d2]"
)}
aria-label="Retirer le surlignage"
title="Retirer le surlignage"
onClick={onClear}
>
<X className="size-3.5" />
</button>
{colors.map((color) => (
<button
key={color}
type="button"
className={cn(
"size-6 rounded-sm border border-border",
isActive &&
currentColor.toLowerCase() === color.toLowerCase() &&
"ring-2 ring-[#1967d2]"
)}
style={{ backgroundColor: color }}
aria-label={color}
onClick={() => onPick(color)}
/>
))}
</div>
</PopoverContent>
</Popover>
)
}

View File

@ -1,7 +1,9 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { usePathname } from "next/navigation"
import { UltiMailLogo } from "@/components/ultimail-logo" import { UltiMailLogo } from "@/components/ultimail-logo"
import { isDriveAppPath } from "@/lib/suite/drive-route"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const SPLASH_SEEN_KEY = "ultimail-splash-seen-v1" const SPLASH_SEEN_KEY = "ultimail-splash-seen-v1"
@ -13,14 +15,19 @@ export function FirstLaunchSplash({
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const pathname = usePathname()
const [isHiding, setIsHiding] = useState(false) const [isHiding, setIsHiding] = useState(false)
const [isComplete, setIsComplete] = useState(false) const [isComplete, setIsComplete] = useState(false)
useEffect(() => { useEffect(() => {
const root = document.documentElement const root = document.documentElement
const alreadySeen = root.dataset.splashSeen === "1" const skipForDrive =
isDriveAppPath(pathname) ||
root.dataset.routeScope === "drive" ||
root.dataset.splashSeen === "1"
if (alreadySeen) { if (skipForDrive) {
if (isDriveAppPath(pathname)) root.dataset.splashSeen = "1"
setIsComplete(true) setIsComplete(true)
return return
} }
@ -43,7 +50,7 @@ export function FirstLaunchSplash({
window.clearTimeout(hideTimer) window.clearTimeout(hideTimer)
window.clearTimeout(completeTimer) window.clearTimeout(completeTimer)
} }
}, []) }, [pathname])
return ( return (
<> <>

View File

@ -1,32 +1,15 @@
"use client" "use client"
import { useEffect } from "react" import { useEffect } from "react"
import { usePathname } from "next/navigation"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { import { applyMailBackgroundDom } from "@/lib/mail-settings/mail-background-dom"
mailBackgroundStyle,
normalizeMailBackgroundId,
} from "@/lib/mail-settings/constants"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store" import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { isDriveAppPath } from "@/lib/suite/drive-route"
function applyMailBackground(backgroundId: string) {
const html = document.documentElement
const normalized = normalizeMailBackgroundId(backgroundId)
const { background, fallbackColor } = mailBackgroundStyle(normalized)
if (normalized === "none" || background === "none") {
delete html.dataset.mailBackground
html.style.removeProperty("--mail-bg-layer")
html.style.removeProperty("--mail-bg-fallback")
return
}
html.dataset.mailBackground = normalized
html.style.setProperty("--mail-bg-layer", background)
html.style.setProperty("--mail-bg-fallback", fallbackColor)
}
/** Applique thème clair/sombre/système et fond décoratif sur le document. */ /** Applique thème clair/sombre/système et fond décoratif sur le document. */
export function MailThemeApplier() { export function MailThemeApplier() {
const pathname = usePathname()
const themeMode = useMailSettingsStore((s) => s.themeMode) const themeMode = useMailSettingsStore((s) => s.themeMode)
const backgroundId = useMailSettingsStore((s) => s.backgroundId) const backgroundId = useMailSettingsStore((s) => s.backgroundId)
const { setTheme } = useTheme() const { setTheme } = useTheme()
@ -36,8 +19,9 @@ export function MailThemeApplier() {
}, [themeMode, setTheme]) }, [themeMode, setTheme])
useEffect(() => { useEffect(() => {
applyMailBackground(backgroundId) if (isDriveAppPath(pathname)) return
}, [backgroundId]) applyMailBackgroundDom(backgroundId)
}, [backgroundId, pathname])
return null return null
} }

View File

@ -4,7 +4,16 @@
export const THEME_INIT_SCRIPT = ` export const THEME_INIT_SCRIPT = `
(function () { (function () {
try { try {
var path = window.location.pathname || "";
var isDrive = path === "/drive" || path.indexOf("/drive/") === 0;
var splashSeen = localStorage.getItem("ultimail-splash-seen-v1") === "1"; var splashSeen = localStorage.getItem("ultimail-splash-seen-v1") === "1";
if (isDrive) {
splashSeen = true;
document.documentElement.dataset.routeScope = "drive";
delete document.documentElement.dataset.mailBackground;
document.documentElement.style.removeProperty("--mail-bg-layer");
document.documentElement.style.removeProperty("--mail-bg-fallback");
}
document.documentElement.dataset.splashSeen = splashSeen ? "1" : "0"; document.documentElement.dataset.splashSeen = splashSeen ? "1" : "0";
var raw = localStorage.getItem("ultimail-mail-settings"); var raw = localStorage.getItem("ultimail-mail-settings");
@ -20,7 +29,7 @@ export const THEME_INIT_SCRIPT = `
: mode; : mode;
document.documentElement.classList.toggle("dark", resolved === "dark"); document.documentElement.classList.toggle("dark", resolved === "dark");
var bgId = state.backgroundId; var bgId = state.backgroundId;
if (bgId && bgId !== "none") { if (!isDrive && bgId && bgId !== "none") {
var legacy = { var legacy = {
mountains: "photo-mountains", mountains: "photo-mountains",
ocean: "gradient-ocean", ocean: "gradient-ocean",

View File

@ -37,6 +37,16 @@ export function useDriveList(path: string, page = 1, q = "", enabled = true) {
}) })
} }
export function useDriveFileById(fileId: string, enabled = true) {
const { ready, authenticated } = useAuthReady()
return useQuery({
queryKey: ["drive", "file", fileId],
enabled: ready && authenticated && enabled && Boolean(fileId),
staleTime: 30_000,
queryFn: () => apiClient.get<DriveFileInfo>(`/drive/files/id/${encodeURIComponent(fileId)}`),
})
}
export function useDriveSharedWithMe(page = 1, q = "", enabled = true) { export function useDriveSharedWithMe(page = 1, q = "", enabled = true) {
const { ready, authenticated } = useAuthReady() const { ready, authenticated } = useAuthReady()
return useQuery({ return useQuery({

View File

@ -364,6 +364,7 @@ export interface DriveFileInfo {
mime_type: string mime_type: string
last_modified: string last_modified: string
etag: string etag: string
file_id?: number
is_favorite: boolean is_favorite: boolean
is_shared?: boolean is_shared?: boolean
/** Suite app dorigine (ultimail, ultimeet, …) — métadonnée ultid, pas le chemin NC. */ /** Suite app dorigine (ultimail, ultimeet, …) — métadonnée ultid, pas le chemin NC. */

View File

@ -0,0 +1,115 @@
import type { Editor } from "@tiptap/react"
import type { Mark } from "@tiptap/pm/model"
export const DOCS_FONT_FAMILIES = [
{ name: "Arial", stack: "Arial, Helvetica, sans-serif" },
{ name: "Calibri", stack: "Calibri, Candara, Segoe, sans-serif" },
{ name: "Comic Sans MS", stack: '"Comic Sans MS", cursive, sans-serif' },
{ name: "Courier New", stack: '"Courier New", Courier, monospace' },
{ name: "Georgia", stack: "Georgia, serif" },
{ name: "Times New Roman", stack: '"Times New Roman", Times, serif' },
{ name: "Trebuchet MS", stack: '"Trebuchet MS", Helvetica, sans-serif' },
{ name: "Verdana", stack: "Verdana, Geneva, sans-serif" },
] as const
export type DocsFontFamilyName = (typeof DOCS_FONT_FAMILIES)[number]["name"]
export const DOCS_DEFAULT_FONT_FAMILY: DocsFontFamilyName = "Arial"
let lastToolbarFontFamily: DocsFontFamilyName = DOCS_DEFAULT_FONT_FAMILY
export type FontFamilyToolbarState =
| { kind: "single"; name: DocsFontFamilyName }
| { kind: "unset" }
function textStyleFontFamilyStack(
marks: ReadonlyArray<{ type: { name: string }; attrs: Record<string, unknown> }>
): string | null {
const mark = marks.find((m) => m.type.name === "textStyle")
const raw = mark?.attrs.fontFamily as string | undefined
return raw?.trim() ? raw : null
}
export function fontFamilyNameForStack(stack: string | null | undefined): DocsFontFamilyName | null {
if (!stack) return null
const normalized = stack.trim().toLowerCase()
for (const family of DOCS_FONT_FAMILIES) {
if (
family.stack.toLowerCase() === normalized ||
family.name.toLowerCase() === normalized ||
normalized.startsWith(`${family.name.toLowerCase()},`) ||
normalized.startsWith(`"${family.name.toLowerCase()}"`)
) {
return family.name
}
}
return null
}
export function fontFamilyStackForName(name: string): string {
return DOCS_FONT_FAMILIES.find((f) => f.name === name)?.stack ?? name
}
function resolveCursorFontFamilyName(editor: Editor): DocsFontFamilyName {
const { state } = editor
const { $from } = state.selection
if (state.storedMarks) {
const stored = fontFamilyNameForStack(textStyleFontFamilyStack(state.storedMarks))
if (stored) return stored
}
const atCursor = fontFamilyNameForStack(textStyleFontFamilyStack($from.marks()))
if (atCursor) return atCursor
if ($from.parent.content.size === 0) return lastToolbarFontFamily
return DOCS_DEFAULT_FONT_FAMILY
}
function effectiveFontFamilyNameAtPos(marks: readonly Mark[]): DocsFontFamilyName {
return fontFamilyNameForStack(textStyleFontFamilyStack(marks)) ?? DOCS_DEFAULT_FONT_FAMILY
}
function collectEffectiveFontFamiliesInRange(
editor: Editor,
from: number,
to: number
): Set<DocsFontFamilyName> {
const names = new Set<DocsFontFamilyName>()
const { state } = editor
state.doc.nodesBetween(from, to, (node, pos) => {
if (!node.isText || !node.text) return
const nodeStart = pos
const nodeEnd = pos + node.text.length
const start = Math.max(from, nodeStart)
const end = Math.min(to, nodeEnd)
if (start >= end) return
names.add(effectiveFontFamilyNameAtPos(node.marks))
})
return names
}
export function readFontFamilyToolbarState(editor: Editor): FontFamilyToolbarState {
const { from, to, empty } = editor.state.selection
if (empty) {
return { kind: "single", name: resolveCursorFontFamilyName(editor) }
}
const names = collectEffectiveFontFamiliesInRange(editor, from, to)
if (names.size === 0) {
return { kind: "single", name: resolveCursorFontFamilyName(editor) }
}
if (names.size === 1) {
return { kind: "single", name: [...names][0]! }
}
return { kind: "unset" }
}
export function applyFontFamily(editor: Editor, name: DocsFontFamilyName) {
lastToolbarFontFamily = name
editor.chain().focus().setFontFamily(fontFamilyStackForName(name)).run()
}

157
lib/drive/docs-font-size.ts Normal file
View File

@ -0,0 +1,157 @@
import type { Editor } from "@tiptap/react"
import type { Mark, ResolvedPos } from "@tiptap/pm/model"
export const DOCS_FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72] as const
export const DOCS_DEFAULT_FONT_SIZE = 11
/** Matches .ultidrive-richtext-editor h1h4 in richtext-editor.css (rem × 16). */
const HEADING_FONT_SIZE_PX: Record<number, number> = {
1: 28,
2: 22,
3: 18,
4: 16,
}
let lastToolbarFontSize = DOCS_DEFAULT_FONT_SIZE
export type FontSizeToolbarState =
| { kind: "single"; size: number }
| { kind: "unset" }
function parseFontSizePx(raw: string | null | undefined): number | null {
if (!raw) return null
const parsed = Number.parseInt(String(raw).replace("px", ""), 10)
return Number.isFinite(parsed) ? parsed : null
}
function nearestFontSizeIndex(current: number): number {
let best = 0
let bestDistance = Number.POSITIVE_INFINITY
DOCS_FONT_SIZES.forEach((size, index) => {
const distance = Math.abs(size - current)
if (distance < bestDistance) {
bestDistance = distance
best = index
}
})
return best
}
function textStyleFontSizePx(
marks: ReadonlyArray<{ type: { name: string }; attrs: Record<string, unknown> }>
): number | null {
const mark = marks.find((m) => m.type.name === "textStyle")
return parseFontSizePx(mark?.attrs.fontSize as string | undefined)
}
function headingLevelAt($pos: ResolvedPos): number | null {
for (let depth = $pos.depth; depth > 0; depth--) {
const node = $pos.node(depth)
if (node.type.name === "heading") {
return node.attrs.level as number
}
}
return null
}
function headingFontSizePx(level: number): number {
return HEADING_FONT_SIZE_PX[level] ?? DOCS_DEFAULT_FONT_SIZE
}
/** Effective size of existing text at a position (no storedMarks). */
function effectiveFontSizeAtPos($pos: ResolvedPos, marks: readonly Mark[]): number {
const marked = textStyleFontSizePx(marks)
if (marked != null) return marked
const level = headingLevelAt($pos)
if (level != null) return headingFontSizePx(level)
return DOCS_DEFAULT_FONT_SIZE
}
function resolveCursorFontSizePx(editor: Editor): number {
const { state } = editor
const { $from } = state.selection
if (state.storedMarks) {
const stored = textStyleFontSizePx(state.storedMarks)
if (stored != null) return stored
}
const atCursor = textStyleFontSizePx($from.marks())
if (atCursor != null) return atCursor
const level = headingLevelAt($from)
if (level != null) return headingFontSizePx(level)
// Empty block: show last toolbar pick (typing size). Existing body text → default.
if ($from.parent.content.size === 0) return lastToolbarFontSize
return DOCS_DEFAULT_FONT_SIZE
}
function collectEffectiveSizesInRange(editor: Editor, from: number, to: number): Set<number> {
const sizes = new Set<number>()
const { state } = editor
state.doc.nodesBetween(from, to, (node, pos) => {
if (!node.isText || !node.text) return
const nodeStart = pos
const nodeEnd = pos + node.text.length
const start = Math.max(from, nodeStart)
const end = Math.min(to, nodeEnd)
if (start >= end) return
sizes.add(effectiveFontSizeAtPos(state.doc.resolve(start), node.marks))
})
return sizes
}
export function readFontSizeToolbarState(editor: Editor): FontSizeToolbarState {
const { from, to, empty } = editor.state.selection
if (empty) {
return { kind: "single", size: resolveCursorFontSizePx(editor) }
}
const sizes = collectEffectiveSizesInRange(editor, from, to)
if (sizes.size === 0) {
return { kind: "single", size: resolveCursorFontSizePx(editor) }
}
if (sizes.size === 1) {
return { kind: "single", size: [...sizes][0]! }
}
return { kind: "unset" }
}
export function readFontSizePxForStep(editor: Editor): number {
const state = readFontSizeToolbarState(editor)
return state.kind === "single" ? state.size : DOCS_DEFAULT_FONT_SIZE
}
export function applyFontSizePx(editor: Editor, size: number) {
lastToolbarFontSize = size
editor.chain().focus().setFontSize(`${size}px`).run()
}
export function stepFontSizePx(editor: Editor, direction: -1 | 1) {
const idx = nearestFontSizeIndex(readFontSizePxForStep(editor))
const nextIdx = Math.min(
DOCS_FONT_SIZES.length - 1,
Math.max(0, idx + direction)
)
applyFontSizePx(editor, DOCS_FONT_SIZES[nextIdx]!)
}
export function canStepFontSizePx(editor: Editor, direction: -1 | 1): boolean {
const idx = nearestFontSizeIndex(readFontSizePxForStep(editor))
if (direction < 0) return idx > 0
return idx < DOCS_FONT_SIZES.length - 1
}
/** @deprecated Use readFontSizeToolbarState */
export function readFontSizePx(editor: Editor): number {
const state = readFontSizeToolbarState(editor)
return state.kind === "single" ? state.size : DOCS_DEFAULT_FONT_SIZE
}

View File

@ -0,0 +1,112 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import {
DEFAULT_PAGE_FORMAT_ID,
type PageFormatId,
} from "@/lib/drive/page-formats"
export type DocsViewSettings = {
pageFormatId: PageFormatId
zoom: number
spellcheck: boolean
chromeCollapsed: boolean
}
const STORAGE_KEY = "ultidrive-docs-view-settings"
const DEFAULT_SETTINGS: DocsViewSettings = {
pageFormatId: DEFAULT_PAGE_FORMAT_ID,
zoom: 100,
spellcheck: true,
chromeCollapsed: false,
}
const ZOOM_MIN = 50
const ZOOM_MAX = 200
const ZOOM_STEP = 10
export function clampZoom(value: number): number {
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(value / ZOOM_STEP) * ZOOM_STEP))
}
function readSettings(): DocsViewSettings {
if (typeof localStorage === "undefined") return DEFAULT_SETTINGS
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return DEFAULT_SETTINGS
const parsed = JSON.parse(raw) as Partial<DocsViewSettings>
return {
pageFormatId: parsed.pageFormatId ?? DEFAULT_PAGE_FORMAT_ID,
zoom: clampZoom(parsed.zoom ?? DEFAULT_SETTINGS.zoom),
spellcheck: parsed.spellcheck ?? DEFAULT_SETTINGS.spellcheck,
chromeCollapsed: parsed.chromeCollapsed ?? DEFAULT_SETTINGS.chromeCollapsed,
}
} catch {
return DEFAULT_SETTINGS
}
}
function writeSettings(settings: DocsViewSettings) {
if (typeof localStorage === "undefined") return
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
}
export function useDocsViewSettings() {
const [settings, setSettings] = useState<DocsViewSettings>(DEFAULT_SETTINGS)
useEffect(() => {
setSettings(readSettings())
}, [])
const setPageFormatId = useCallback((pageFormatId: PageFormatId) => {
setSettings((prev) => {
const next = { ...prev, pageFormatId }
writeSettings(next)
return next
})
}, [])
const setZoom = useCallback((zoom: number) => {
setSettings((prev) => {
const next = { ...prev, zoom: clampZoom(zoom) }
writeSettings(next)
return next
})
}, [])
const setSpellcheck = useCallback((spellcheck: boolean) => {
setSettings((prev) => {
const next = { ...prev, spellcheck }
writeSettings(next)
return next
})
}, [])
const toggleSpellcheck = useCallback(() => {
setSettings((prev) => {
const next = { ...prev, spellcheck: !prev.spellcheck }
writeSettings(next)
return next
})
}, [])
const toggleChromeCollapsed = useCallback(() => {
setSettings((prev) => {
const next = { ...prev, chromeCollapsed: !prev.chromeCollapsed }
writeSettings(next)
return next
})
}, [])
return {
settings,
setPageFormatId,
setZoom,
setSpellcheck,
toggleSpellcheck,
toggleChromeCollapsed,
zoomMin: ZOOM_MIN,
zoomMax: ZOOM_MAX,
}
}

View File

@ -0,0 +1,28 @@
const RETURN_KEY = "ultidrive-editor-return-to"
function isSafeDriveReturnPath(path: string): boolean {
if (!path.startsWith("/drive")) return false
if (path.startsWith("//")) return false
if (path.includes("://")) return false
return true
}
/** Remember browser location before opening an editor (not stored in URL). */
export function stashDriveEditorReturnTo(path?: string) {
if (typeof window === "undefined") return
const href = path ?? window.location.pathname + window.location.search
if (isSafeDriveReturnPath(href)) {
sessionStorage.setItem(RETURN_KEY, href)
}
}
export function readDriveEditorReturnTo(): string | null {
if (typeof window === "undefined") return null
const value = sessionStorage.getItem(RETURN_KEY)
return value && isSafeDriveReturnPath(value) ? value : null
}
export function clearDriveEditorReturnTo() {
if (typeof window === "undefined") return
sessionStorage.removeItem(RETURN_KEY)
}

View File

@ -9,6 +9,8 @@ import {
} from "@/lib/drive/drive-preview" } from "@/lib/drive/drive-preview"
import { driveFolderHref } from "@/lib/drive/drive-sidebar-tree" import { driveFolderHref } from "@/lib/drive/drive-sidebar-tree"
import { buildDriveEditHref } from "@/lib/drive/drive-url" import { buildDriveEditHref } from "@/lib/drive/drive-url"
import { openRichTextDocument } from "@/lib/drive/open-rich-text-document"
import { stashDriveEditorReturnTo } from "@/lib/drive/drive-editor-return"
import type { DrivePreviewContext } from "@/lib/stores/drive-ui-store" import type { DrivePreviewContext } from "@/lib/stores/drive-ui-store"
export interface OpenDriveItemRouter { export interface OpenDriveItemRouter {
@ -59,15 +61,14 @@ export function openDriveItem(file: DriveFileInfo, options: OpenDriveItemOptions
} }
if (shouldOpenInRichTextEditor(file)) { if (shouldOpenInRichTextEditor(file)) {
const returnTo = void openRichTextDocument(file, router).catch(() => {
typeof window !== "undefined" window.alert("Impossible douvrir le document.")
? window.location.pathname + window.location.search })
: undefined
router.push(buildDriveEditHref(file.path, returnTo, "richtext"))
return return
} }
if (shouldOpenInOnlyOffice(file)) { if (shouldOpenInOnlyOffice(file)) {
stashDriveEditorReturnTo()
const returnTo = const returnTo =
typeof window !== "undefined" typeof window !== "undefined"
? window.location.pathname + window.location.search ? window.location.pathname + window.location.search

View File

@ -146,13 +146,18 @@ function isSafeDriveReturnPath(path: string): boolean {
return true return true
} }
export function buildDriveEditHref( /** Stable Nextcloud file id segment (numeric). */
filePath: string, export function isDriveFileIdSegment(value: string): boolean {
returnTo?: string, return /^[1-9][0-9]*$/.test(value)
editor: "office" | "richtext" = "office" }
): string {
/** Rich-text editor URL — id-only, no returnTo query param. */
export function buildDriveDocsEditHref(fileId: string | number): string {
return `/drive/docs/${String(fileId)}/edit`
}
export function buildDriveEditHref(filePath: string, returnTo?: string): string {
const params = new URLSearchParams() const params = new URLSearchParams()
if (editor === "richtext") params.set("editor", "richtext")
if (returnTo && isSafeDriveReturnPath(returnTo)) { if (returnTo && isSafeDriveReturnPath(returnTo)) {
params.set("returnTo", returnTo) params.set("returnTo", returnTo)
} }
@ -161,12 +166,14 @@ export function buildDriveEditHref(
return qs ? `${base}?${qs}` : base return qs ? `${base}?${qs}` : base
} }
/** Resolve back link from editor: prefer explicit returnTo, else parent folder. */ /** Resolve back link from editor: prefer session returnTo, else explicit returnTo, else parent folder. */
export function resolveDriveEditReturnTo( export function resolveDriveEditReturnTo(
returnTo: string | null | undefined, returnTo: string | null | undefined,
filePath: string, filePath: string,
folderHref: (folderPath: string) => string folderHref: (folderPath: string) => string,
sessionReturnTo?: string | null
): string { ): string {
if (sessionReturnTo && isSafeDriveReturnPath(sessionReturnTo)) return sessionReturnTo
if (returnTo && isSafeDriveReturnPath(returnTo)) return returnTo if (returnTo && isSafeDriveReturnPath(returnTo)) return returnTo
return folderHref(parentFolderPathFromFilePath(filePath)) return folderHref(parentFolderPathFromFilePath(filePath))
} }

View File

@ -0,0 +1,32 @@
import type { Editor } from "@tiptap/react"
/** Focus editor at viewport coords; clamp to prose bounds when click is on padding. */
export function focusEditorAtPointer(
editor: Editor,
clientX: number,
clientY: number
): void {
if (editor.isDestroyed || !editor.isInitialized) return
const direct = editor.view.posAtCoords({ left: clientX, top: clientY })
if (direct) {
editor.chain().focus().setTextSelection(direct.pos).run()
return
}
const rect = editor.view.dom.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) {
editor.chain().focus("end").run()
return
}
const clampedLeft = Math.min(Math.max(clientX, rect.left + 1), rect.right - 1)
const clampedTop = Math.min(Math.max(clientY, rect.top + 1), rect.bottom - 1)
const clamped = editor.view.posAtCoords({ left: clampedLeft, top: clampedTop })
if (clamped) {
editor.chain().focus().setTextSelection(clamped.pos).run()
return
}
editor.chain().focus(clientY < rect.top + rect.height / 2 ? "start" : "end").run()
}

View File

@ -0,0 +1,23 @@
import { apiClient } from "@/lib/api/client"
import type { DriveFileInfo } from "@/lib/api/types"
import { stashDriveEditorReturnTo } from "@/lib/drive/drive-editor-return"
import { buildDriveDocsEditHref } from "@/lib/drive/drive-url"
import type { OpenDriveItemRouter } from "@/lib/drive/drive-open-item"
export async function openRichTextDocument(
file: DriveFileInfo,
router: OpenDriveItemRouter
) {
stashDriveEditorReturnTo()
let fileId = file.file_id
if (!fileId) {
const info = await apiClient.get<DriveFileInfo>(
`/drive/files/info${file.path.startsWith("/") ? file.path : `/${file.path}`}`
)
fileId = info.file_id
}
if (!fileId) {
throw new Error("Identifiant du document introuvable")
}
router.push(buildDriveDocsEditHref(fileId))
}

36
lib/drive/page-formats.ts Normal file
View File

@ -0,0 +1,36 @@
export type PageFormatId = "a4" | "letter" | "legal" | "a5" | "tabloid"
export type PageFormat = {
id: PageFormatId
label: string
widthMm: number
heightMm: number
}
/** 96 CSS px per inch, 25.4 mm per inch */
const MM_TO_PX = 96 / 25.4
export const PAGE_FORMATS: PageFormat[] = [
{ id: "a4", label: "A4", widthMm: 210, heightMm: 297 },
{ id: "letter", label: "Letter", widthMm: 216, heightMm: 279 },
{ id: "legal", label: "Legal", widthMm: 216, heightMm: 356 },
{ id: "a5", label: "A5", widthMm: 148, heightMm: 210 },
{ id: "tabloid", label: "Tabloid", widthMm: 279, heightMm: 432 },
]
export const DEFAULT_PAGE_FORMAT_ID: PageFormatId = "a4"
export function getPageFormat(id: PageFormatId): PageFormat {
return PAGE_FORMATS.find((f) => f.id === id) ?? PAGE_FORMATS[0]!
}
export function pageFormatWidthPx(format: PageFormat): number {
return Math.round(format.widthMm * MM_TO_PX)
}
export function pageFormatHeightPx(format: PageFormat): number {
return Math.round(format.heightMm * MM_TO_PX)
}
/** Approximate print margins (1 inch) */
export const PAGE_MARGIN_PX = Math.round(25.4 * MM_TO_PX)

View File

@ -3,7 +3,7 @@ import StarterKit from "@tiptap/starter-kit"
import Underline from "@tiptap/extension-underline" import Underline from "@tiptap/extension-underline"
import Link from "@tiptap/extension-link" import Link from "@tiptap/extension-link"
import TextAlign from "@tiptap/extension-text-align" import TextAlign from "@tiptap/extension-text-align"
import { TextStyle, Color, BackgroundColor } from "@tiptap/extension-text-style" import { TextStyle, Color, BackgroundColor, FontSize, FontFamily } from "@tiptap/extension-text-style"
import Highlight from "@tiptap/extension-highlight" import Highlight from "@tiptap/extension-highlight"
import Image from "@tiptap/extension-image" import Image from "@tiptap/extension-image"
import Placeholder from "@tiptap/extension-placeholder" import Placeholder from "@tiptap/extension-placeholder"
@ -50,9 +50,11 @@ export function buildRichTextExtensions(options?: {
Underline, Underline,
Link.configure({ openOnClick: false }), Link.configure({ openOnClick: false }),
TextStyle, TextStyle,
FontFamily,
FontSize,
Color, Color,
BackgroundColor, BackgroundColor,
Highlight, Highlight.configure({ multicolor: true }),
TextAlign.configure({ types: ["heading", "paragraph"], alignments: ["left", "center", "right", "justify"] }), TextAlign.configure({ types: ["heading", "paragraph"], alignments: ["left", "center", "right", "justify"] }),
Table.configure({ resizable: true }), Table.configure({ resizable: true }),
TableRow, TableRow,
@ -68,4 +70,4 @@ export function buildRichTextExtensions(options?: {
} }
export const RICHTEXT_EDITOR_CLASS = export const RICHTEXT_EDITOR_CLASS =
"prose prose-sm dark:prose-invert max-w-none min-h-full px-8 py-6 outline-none focus:outline-none ultidrive-richtext-editor" "ultidrive-richtext-editor max-w-none outline-none focus:outline-none prose prose-sm"

View File

@ -0,0 +1,63 @@
"use client"
import { useEffect, useState } from "react"
import type { HocuspocusProvider } from "@hocuspocus/provider"
import { colorForGuestId } from "@/lib/drive/guest-editor-identity"
export type CollabPresenceUser = {
clientId: number
name: string
color: string
isLocal: boolean
}
type AwarenessUser = {
name?: string
color?: string
}
export function useCollabPresence(
provider: HocuspocusProvider | null,
localUser: { name: string; color: string }
): CollabPresenceUser[] {
const [users, setUsers] = useState<CollabPresenceUser[]>([
{ clientId: -1, name: localUser.name, color: localUser.color, isLocal: true },
])
useEffect(() => {
if (!provider) {
setUsers([
{ clientId: -1, name: localUser.name, color: localUser.color, isLocal: true },
])
return
}
const awareness = provider.awareness
if (!awareness) return
const sync = () => {
const list: CollabPresenceUser[] = []
awareness.getStates().forEach((state, clientId) => {
const user = state.user as AwarenessUser | undefined
const name = user?.name?.trim()
if (!name) return
list.push({
clientId,
name,
color: user?.color && user.color.length > 0 ? user.color : colorForGuestId(String(clientId)),
isLocal: clientId === awareness.clientID,
})
})
list.sort((a, b) => Number(b.isLocal) - Number(a.isLocal) || a.name.localeCompare(b.name, "fr"))
setUsers(list.length > 0 ? list : [{ clientId: -1, name: localUser.name, color: localUser.color, isLocal: true }])
}
awareness.on("change", sync)
sync()
return () => {
awareness.off("change", sync)
}
}, [provider, localUser.name, localUser.color])
return users
}

View File

@ -0,0 +1,152 @@
"use client"
import { useEditorState } from "@tiptap/react"
import type { Editor } from "@tiptap/react"
import {
canStepFontSizePx,
readFontSizeToolbarState,
type FontSizeToolbarState,
} from "@/lib/drive/docs-font-size"
import {
readFontFamilyToolbarState,
type FontFamilyToolbarState,
} from "@/lib/drive/docs-font-family"
export const DOCS_TOOLBAR_DEFAULT_TEXT_COLOR = "#000000"
function activeTextStyle(editor: Editor): string {
if (editor.isActive("heading", { level: 1 })) return "heading1"
if (editor.isActive("heading", { level: 2 })) return "heading2"
if (editor.isActive("heading", { level: 3 })) return "heading3"
if (editor.isActive("heading", { level: 4 })) return "heading4"
return "paragraph"
}
export type DocsToolbarEditorState = {
canUndo: boolean
canRedo: boolean
styleId: string
fontFamilyState: FontFamilyToolbarState
fontSizeState: FontSizeToolbarState
canStepFontSizeDown: boolean
canStepFontSizeUp: boolean
textColor: string
highlightColor: string | null
isBold: boolean
isItalic: boolean
isUnderline: boolean
isLink: boolean
alignLeft: boolean
alignCenter: boolean
alignRight: boolean
alignJustify: boolean
isBulletList: boolean
isOrderedList: boolean
}
function readHighlightColor(editor: Editor): string | null {
const { storedMarks } = editor.state
if (storedMarks) {
const mark = storedMarks.find((m) => m.type.name === "highlight")
const stored = mark?.attrs.color as string | undefined
if (stored) return stored
}
if (!editor.isActive("highlight")) return null
const color = editor.getAttributes("highlight").color as string | undefined
return color ?? null
}
function selectDocsToolbarState({ editor }: { editor: Editor }): DocsToolbarEditorState {
const highlightColor = readHighlightColor(editor)
return {
canUndo: editor.can().chain().focus().undo().run(),
canRedo: editor.can().chain().focus().redo().run(),
styleId: activeTextStyle(editor),
fontFamilyState: readFontFamilyToolbarState(editor),
fontSizeState: readFontSizeToolbarState(editor),
canStepFontSizeDown: canStepFontSizePx(editor, -1),
canStepFontSizeUp: canStepFontSizePx(editor, 1),
textColor:
(editor.getAttributes("textStyle").color as string | undefined) ||
DOCS_TOOLBAR_DEFAULT_TEXT_COLOR,
highlightColor,
isBold: editor.isActive("bold"),
isItalic: editor.isActive("italic"),
isUnderline: editor.isActive("underline"),
isLink: editor.isActive("link"),
alignLeft: editor.isActive({ textAlign: "left" }),
alignCenter: editor.isActive({ textAlign: "center" }),
alignRight: editor.isActive({ textAlign: "right" }),
alignJustify: editor.isActive({ textAlign: "justify" }),
isBulletList: editor.isActive("bulletList"),
isOrderedList: editor.isActive("orderedList"),
}
}
function fontFamilyStateEqual(a: FontFamilyToolbarState, b: FontFamilyToolbarState): boolean {
if (a.kind !== b.kind) return false
if (a.kind === "single" && b.kind === "single") return a.name === b.name
return true
}
function fontSizeStateEqual(a: FontSizeToolbarState, b: FontSizeToolbarState): boolean {
if (a.kind !== b.kind) return false
if (a.kind === "single" && b.kind === "single") return a.size === b.size
return true
}
function toolbarStateEqual(
a: DocsToolbarEditorState,
b: DocsToolbarEditorState | null
): boolean {
if (!b) return false
if (!fontFamilyStateEqual(a.fontFamilyState, b.fontFamilyState)) return false
if (!fontSizeStateEqual(a.fontSizeState, b.fontSizeState)) return false
return (
a.canUndo === b.canUndo &&
a.canRedo === b.canRedo &&
a.styleId === b.styleId &&
a.canStepFontSizeDown === b.canStepFontSizeDown &&
a.canStepFontSizeUp === b.canStepFontSizeUp &&
a.textColor === b.textColor &&
a.highlightColor === b.highlightColor &&
a.isBold === b.isBold &&
a.isItalic === b.isItalic &&
a.isUnderline === b.isUnderline &&
a.isLink === b.isLink &&
a.alignLeft === b.alignLeft &&
a.alignCenter === b.alignCenter &&
a.alignRight === b.alignRight &&
a.alignJustify === b.alignJustify &&
a.isBulletList === b.isBulletList &&
a.isOrderedList === b.isOrderedList
)
}
function selectDocsToolbarStateOptional({
editor,
}: {
editor: Editor | null
}): DocsToolbarEditorState | null {
if (!editor) return null
return selectDocsToolbarState({ editor })
}
function toolbarStateEqualOptional(
a: DocsToolbarEditorState | null,
b: DocsToolbarEditorState | null
): boolean {
if (a == null || b == null) return a === b
return toolbarStateEqual(a, b)
}
export function useDocsToolbarState(editor: Editor | null): DocsToolbarEditorState | null {
return useEditorState({
editor,
selector: selectDocsToolbarStateOptional,
equalityFn: toolbarStateEqualOptional,
})
}

View File

@ -0,0 +1,50 @@
"use client"
import { useLayoutEffect, useRef, useState } from "react"
const OVERFLOW_BUTTON_WIDTH = 36
export function useToolbarOverflow(itemCount: number) {
const containerRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLDivElement>(null)
const [visibleCount, setVisibleCount] = useState(itemCount)
useLayoutEffect(() => {
const container = containerRef.current
const measure = measureRef.current
if (!container || !measure) return
const recalculate = () => {
const children = Array.from(measure.children) as HTMLElement[]
if (children.length === 0) return
const totalWidth = children.reduce((sum, child) => sum + child.offsetWidth, 0)
if (totalWidth <= container.clientWidth) {
setVisibleCount(children.length)
return
}
const available = container.clientWidth - OVERFLOW_BUTTON_WIDTH
let used = 0
let fit = 0
for (const child of children) {
const width = child.offsetWidth
if (used + width > available) break
used += width
fit += 1
}
setVisibleCount(Math.max(1, fit))
}
recalculate()
const ro = new ResizeObserver(recalculate)
ro.observe(container)
return () => ro.disconnect()
}, [itemCount])
const hasOverflow = visibleCount < itemCount
return { containerRef, measureRef, visibleCount, hasOverflow }
}

View File

@ -0,0 +1,27 @@
import {
mailBackgroundStyle,
normalizeMailBackgroundId,
} from "@/lib/mail-settings/constants"
export function clearMailBackgroundDom(html: HTMLElement = document.documentElement) {
delete html.dataset.mailBackground
html.style.removeProperty("--mail-bg-layer")
html.style.removeProperty("--mail-bg-fallback")
}
export function applyMailBackgroundDom(
backgroundId: string,
html: HTMLElement = document.documentElement
) {
const normalized = normalizeMailBackgroundId(backgroundId)
const { background, fallbackColor } = mailBackgroundStyle(normalized)
if (normalized === "none" || background === "none") {
clearMailBackgroundDom(html)
return
}
html.dataset.mailBackground = normalized
html.style.setProperty("--mail-bg-layer", background)
html.style.setProperty("--mail-bg-fallback", fallbackColor)
}

4
lib/suite/drive-route.ts Normal file
View File

@ -0,0 +1,4 @@
/** True for any in-app Drive route (`/drive`, `/drive/...`). */
export function isDriveAppPath(pathname: string): boolean {
return pathname === "/drive" || pathname.startsWith("/drive/")
}

View File

@ -38,3 +38,386 @@
padding: 6px 8px; padding: 6px 8px;
vertical-align: top; vertical-align: top;
} }
/* Google Docsstyle chrome */
.docs-menu-trigger {
height: auto;
padding: 2px 8px;
font-size: 13px;
font-weight: 400;
color: #3c4043;
background: transparent;
border: none;
border-radius: 4px;
box-shadow: none;
}
.docs-menu-trigger:hover,
.docs-menu-trigger[data-state="open"] {
background: #e8eaed;
}
.dark .docs-menu-trigger {
color: hsl(var(--foreground));
}
.dark .docs-menu-trigger:hover,
.dark .docs-menu-trigger[data-state="open"] {
background: hsl(var(--muted));
}
.docs-toolbar-btn:hover:not(:disabled) {
background: #d3e3fd;
}
.dark .docs-toolbar-btn:hover:not(:disabled) {
background: #444746;
}
.docs-toolbar-btn--active {
background: #d3e3fd;
}
.dark .docs-toolbar-btn--active {
background: #444746;
}
.docs-menubar {
margin-left: -3px;
}
/* Align title first letter with "Fichier" (menubar -3px, title field px-2). */
.docs-chrome-title-row {
margin-left: -5px;
}
.docs-menubar [data-slot="menubar-menu"]:first-child .docs-menu-trigger {
padding-left: 0;
margin-left: 0;
}
.docs-toolbar-shell {
padding: 0 12px 8px;
}
.docs-toolbar-shell--collapsed {
padding-top: 8px;
}
.docs-toolbar {
flex-wrap: nowrap;
color: #202124;
background: #edf2fa;
border-radius: 9999px;
}
.dark .docs-toolbar {
color: #e8eaed;
background: #2d2e30;
}
.docs-toolbar .docs-toolbar-btn {
color: inherit;
}
.docs-toolbar-select {
font-size: 13px;
color: inherit;
background: transparent !important;
box-shadow: none !important;
}
.docs-toolbar [data-slot="select-trigger"].docs-toolbar-select:hover,
.docs-toolbar [data-slot="select-trigger"].docs-toolbar-select:focus-visible,
.docs-toolbar [data-slot="select-trigger"].docs-toolbar-select[data-state="open"],
.dark .docs-toolbar [data-slot="select-trigger"].docs-toolbar-select:hover,
.dark .docs-toolbar [data-slot="select-trigger"].docs-toolbar-select:focus-visible,
.dark .docs-toolbar [data-slot="select-trigger"].docs-toolbar-select[data-state="open"] {
background: transparent !important;
box-shadow: none !important;
}
.docs-toolbar-select [data-slot="select-value"] {
color: inherit;
}
.dark .docs-toolbar [data-slot="select-trigger"].docs-toolbar-select {
color: #e8eaed;
}
.dark .docs-toolbar [data-slot="select-trigger"].docs-toolbar-select [data-slot="select-value"] {
color: #e8eaed;
}
.dark .docs-toolbar [data-slot="select-trigger"].docs-toolbar-select svg {
color: #e8eaed;
opacity: 0.75;
}
.docs-toolbar-select--placeholder {
opacity: 0.55;
}
.dark .docs-toolbar-select--placeholder {
opacity: 1;
color: #e8eaed;
}
.dark .docs-toolbar-select--placeholder [data-slot="select-value"] {
color: #e8eaed;
}
.docs-toolbar-icon {
color: #3c4043;
}
.dark .docs-toolbar-icon {
color: #e8eaed;
}
.docs-toolbar-sep {
display: inline-block;
align-self: center;
flex-shrink: 0;
width: 0;
height: 18px;
margin-inline: 4px;
border-left: 1px solid #dadce0;
background: none;
}
.dark .docs-toolbar .docs-toolbar-sep {
border-left-color: #80868b;
background: none;
}
.docs-toolbar-select--size {
justify-content: center;
gap: 0 !important;
width: 26px;
min-width: 26px;
height: 22px;
min-height: 22px;
padding: 0 1px !important;
font-size: 12px;
line-height: 1;
border: 1px solid #dadce0 !important;
border-radius: 3px;
}
.docs-toolbar-font-size {
gap: 3px;
}
.docs-toolbar-btn--size-step {
width: 20px;
height: 20px;
min-width: 20px;
padding: 0 !important;
}
.dark .docs-toolbar .docs-toolbar-select--size {
border-color: #9aa0a6 !important;
}
.docs-toolbar-select--size:hover,
.docs-toolbar-select--size:focus-visible,
.docs-toolbar-select--size[data-state="open"] {
border-color: #bdc1c6 !important;
}
.dark .docs-toolbar .docs-toolbar-select--size:hover,
.dark .docs-toolbar .docs-toolbar-select--size:focus-visible,
.dark .docs-toolbar .docs-toolbar-select--size[data-state="open"] {
border-color: #bdc1c6 !important;
}
.docs-toolbar-select--size [data-slot="select-value"] {
flex: 1;
justify-content: center;
text-align: center;
padding: 0;
line-height: 1;
}
.docs-toolbar-select--size svg {
display: none;
}
/* Dropdown previews — text style & font family */
.docs-toolbar-select-content--style {
min-width: 220px;
}
.docs-toolbar-select-content--font {
min-width: 196px;
}
.docs-toolbar-style-item {
align-items: center;
min-height: auto;
padding-top: 0.375rem;
padding-bottom: 0.375rem;
}
.docs-toolbar-style-preview {
display: block;
line-height: 1.25;
color: inherit;
}
.docs-toolbar-style-preview--paragraph {
font-size: 0.875rem;
font-weight: 400;
}
.docs-toolbar-style-preview--heading1 {
font-size: 1.75rem;
font-weight: 400;
line-height: 1.15;
}
.docs-toolbar-style-preview--heading2 {
font-size: 1.375rem;
font-weight: 400;
line-height: 1.2;
}
.docs-toolbar-style-preview--heading3 {
font-size: 1.125rem;
font-weight: 400;
}
.docs-toolbar-style-preview--heading4 {
font-size: 1rem;
font-weight: 600;
}
.docs-toolbar-font-preview {
display: block;
font-size: 0.875rem;
line-height: 1.25;
color: inherit;
}
.docs-toolbar-color-glyph {
position: relative;
display: inline-flex;
width: 18px;
height: 18px;
flex-shrink: 0;
align-items: flex-start;
justify-content: center;
}
.docs-toolbar-color-glyph__icon {
width: 18px;
height: 18px;
transform: translateY(-1px);
clip-path: inset(0 0 5px 0);
}
.docs-toolbar-color-glyph--highlight .docs-toolbar-color-glyph__icon {
clip-path: inset(0 0 3px 0);
}
.docs-toolbar-color-glyph__swatch {
position: absolute;
bottom: 0;
left: 50%;
width: 16px;
height: 3px;
transform: translateX(-50%);
border-radius: 1px;
}
.ultidrive-docs-editor-surface .ProseMirror {
min-height: var(--docs-prose-min-height, 600px);
}
.ultidrive-richtext-editor.prose {
--tw-prose-body: #000000;
--tw-prose-headings: #000000;
--tw-prose-bold: #000000;
--tw-prose-counters: #5f6368;
--tw-prose-bullets: #5f6368;
color: #000000;
}
.ultidrive-docs-canvas {
scrollbar-width: auto;
}
.dark .ultidrive-docs-canvas {
scrollbar-color: #5f6368 #202124;
}
.dark .ultidrive-docs-canvas::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.dark .ultidrive-docs-canvas::-webkit-scrollbar-track {
background: #202124;
}
.dark .ultidrive-docs-canvas::-webkit-scrollbar-thumb {
background-color: #5f6368;
border: 3px solid #202124;
border-radius: 8px;
}
.dark .ultidrive-docs-canvas::-webkit-scrollbar-thumb:hover {
background-color: #80868b;
}
.dark .ultidrive-docs-canvas::-webkit-scrollbar-corner {
background: #202124;
}
.ultidrive-docs-page {
border: 1px solid #dadce0;
}
.ultidrive-docs-page .ultidrive-richtext-editor {
min-height: 100%;
color: #000000;
}
.ultidrive-richtext-editor .ProseMirror {
color: #000000;
caret-color: #000000;
}
.ultidrive-richtext-editor .ProseMirror p.is-editor-empty:first-child::before {
color: #80868b;
}
.ultidrive-richtext-editor p {
margin-top: 0;
margin-bottom: 0.75em;
}
.ultidrive-richtext-editor h1 {
font-size: 1.75rem;
font-weight: 400;
margin-bottom: 0.5em;
}
.ultidrive-richtext-editor h2 {
font-size: 1.375rem;
font-weight: 400;
margin-bottom: 0.5em;
}
.ultidrive-richtext-editor h3 {
font-size: 1.125rem;
font-weight: 400;
margin-bottom: 0.5em;
}
.ultidrive-richtext-editor h4 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5em;
}

File diff suppressed because one or more lines are too long