hehehehe
This commit is contained in:
parent
cdff12490a
commit
5b1cc5e83c
17
app/drive/docs/[fileId]/edit/layout.tsx
Normal file
17
app/drive/docs/[fileId]/edit/layout.tsx
Normal 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
|
||||||
|
}
|
||||||
20
app/drive/docs/[fileId]/edit/page.tsx
Normal file
20
app/drive/docs/[fileId]/edit/page.tsx
Normal 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} />
|
||||||
|
}
|
||||||
@ -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} />
|
||||||
|
|||||||
@ -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}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
20
components/drive/drive-route-scope.tsx
Normal file
20
components/drive/drive-route-scope.tsx
Normal 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
|
||||||
|
}
|
||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
61
components/drive/richtext-legacy-redirect.tsx
Normal file
61
components/drive/richtext-legacy-redirect.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
components/drive/richtext/collab-presence-avatars.tsx
Normal file
67
components/drive/richtext/collab-presence-avatars.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
185
components/drive/richtext/docs-chrome.tsx
Normal file
185
components/drive/richtext/docs-chrome.tsx
Normal 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}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
components/drive/richtext/docs-logo-icon.tsx
Normal file
31
components/drive/richtext/docs-logo-icon.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
100
components/drive/richtext/docs-menubar.tsx
Normal file
100
components/drive/richtext/docs-menubar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
components/drive/richtext/docs-move-button.tsx
Normal file
45
components/drive/richtext/docs-move-button.tsx
Normal 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))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
214
components/drive/richtext/docs-page-view.tsx
Normal file
214
components/drive/richtext/docs-page-view.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
923
components/drive/richtext/docs-toolbar.tsx
Normal file
923
components/drive/richtext/docs-toolbar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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 d’origine (ultimail, ultimeet, …) — métadonnée ultid, pas le chemin NC. */
|
/** Suite app d’origine (ultimail, ultimeet, …) — métadonnée ultid, pas le chemin NC. */
|
||||||
|
|||||||
115
lib/drive/docs-font-family.ts
Normal file
115
lib/drive/docs-font-family.ts
Normal 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
157
lib/drive/docs-font-size.ts
Normal 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 h1–h4 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
|
||||||
|
}
|
||||||
112
lib/drive/docs-view-settings.ts
Normal file
112
lib/drive/docs-view-settings.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
28
lib/drive/drive-editor-return.ts
Normal file
28
lib/drive/drive-editor-return.ts
Normal 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)
|
||||||
|
}
|
||||||
@ -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 d’ouvrir 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
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
32
lib/drive/focus-editor-at-pointer.ts
Normal file
32
lib/drive/focus-editor-at-pointer.ts
Normal 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()
|
||||||
|
}
|
||||||
23
lib/drive/open-rich-text-document.ts
Normal file
23
lib/drive/open-rich-text-document.ts
Normal 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
36
lib/drive/page-formats.ts
Normal 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)
|
||||||
@ -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"
|
||||||
|
|||||||
63
lib/drive/use-collab-presence.ts
Normal file
63
lib/drive/use-collab-presence.ts
Normal 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
|
||||||
|
}
|
||||||
152
lib/drive/use-docs-toolbar-state.ts
Normal file
152
lib/drive/use-docs-toolbar-state.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
50
lib/drive/use-toolbar-overflow.ts
Normal file
50
lib/drive/use-toolbar-overflow.ts
Normal 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 }
|
||||||
|
}
|
||||||
27
lib/mail-settings/mail-background-dom.ts
Normal file
27
lib/mail-settings/mail-background-dom.ts
Normal 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
4
lib/suite/drive-route.ts
Normal 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/")
|
||||||
|
}
|
||||||
@ -38,3 +38,386 @@
|
|||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Google Docs–style 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
Loading…
Reference in New Issue
Block a user