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

This commit is contained in:
R3D347HR4Y 2026-06-09 14:31:07 +02:00
parent 5304790ed5
commit cdff12490a
36 changed files with 2371 additions and 66 deletions

View File

@ -17,5 +17,7 @@ NEXT_PUBLIC_APP_URL=http://localhost
# Secret serveur uniquement — doit matcher ULTID_OIDC_CLIENT_SECRET / blueprint
OIDC_CLIENT_SECRET=changeme
# OnlyOffice Document Server (UltiDrive editor)
# OnlyOffice editor (UltiDrive — tableurs/présentations)
NEXT_PUBLIC_ONLYOFFICE_URL=http://localhost/office
# Rich text editor (TipTap + Hocuspocus — docs texte)
NEXT_PUBLIC_HOCUSPOCUS_URL=ws://localhost/collab

View File

@ -2,11 +2,22 @@
import { useParams, useSearchParams } from "next/navigation"
import { OfficeEditor } from "@/components/drive/office-editor"
import { RichTextEditor } from "@/components/drive/richtext-editor"
import { shouldOpenInRichTextEditor } from "@/lib/drive/drive-preview"
export default function DriveEditPage() {
const params = useParams()
const searchParams = useSearchParams()
const filePath = decodeURIComponent(params.fileId as string)
const returnTo = searchParams.get("returnTo")
const editorParam = searchParams.get("editor")
const fileName = filePath.split("/").pop() ?? filePath
const useRichText =
editorParam === "richtext" || shouldOpenInRichTextEditor({ name: fileName })
if (useRichText) {
return <RichTextEditor filePath={filePath} returnTo={returnTo} />
}
return <OfficeEditor filePath={filePath} returnTo={returnTo} />
}

View File

@ -1,9 +1,12 @@
"use client"
import { useParams, useSearchParams } from "next/navigation"
import { useState } from "react"
import { useEffect, useState } from "react"
import { PublicOfficeEditor } from "@/components/drive/public-office-editor"
import { filePathFromPublicEditSegments } from "@/lib/drive/public-share-url"
import { PublicRichTextEditor } from "@/components/drive/public-richtext-editor"
import { shouldOpenInRichTextEditor } from "@/lib/drive/drive-preview"
import { filePathFromPublicEditSegments, readPublicShareRootType } from "@/lib/drive/public-share-url"
import type { PublicShareRootType } from "@/lib/drive/public-share-url"
export default function PublicShareEditPage() {
const params = useParams()
@ -14,11 +17,45 @@ export default function PublicShareEditPage() {
const returnTo = searchParams.get("returnTo")
const mode = searchParams.get("mode") === "view" ? "view" : "edit"
const fileDisplayName = searchParams.get("name") ?? undefined
const editorParam = searchParams.get("editor")
const shareRootParam = searchParams.get("shareRoot")
const shareRootFromUrl: PublicShareRootType | null =
shareRootParam === "file" || shareRootParam === "folder" ? shareRootParam : null
const [shareRoot, setShareRoot] = useState<PublicShareRootType | null>(shareRootFromUrl)
useEffect(() => {
if (shareRootFromUrl) {
setShareRoot(shareRootFromUrl)
return
}
setShareRoot(readPublicShareRootType(token))
}, [shareRootFromUrl, token])
const [password] = useState<string | undefined>(() => {
if (typeof window === "undefined") return undefined
return sessionStorage.getItem(`public-share-pw:${token}`) ?? undefined
})
const useRichText =
editorParam === "richtext" ||
shouldOpenInRichTextEditor({
name: fileDisplayName ?? filePath.split("/").pop() ?? "",
})
if (useRichText) {
return (
<PublicRichTextEditor
token={token}
filePath={filePath}
password={password}
returnTo={returnTo}
mode={mode}
fileDisplayName={fileDisplayName}
shareRoot={shareRoot}
/>
)
}
return (
<PublicOfficeEditor
token={token}
@ -27,6 +64,7 @@ export default function PublicShareEditPage() {
returnTo={returnTo}
mode={mode}
fileDisplayName={fileDisplayName}
shareRoot={shareRoot}
/>
)
}

View File

@ -1,6 +1,7 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import '../styles/onlyoffice-theme.css';
@import '../styles/richtext-editor.css';
@custom-variant dark (&:is(.dark *));

View File

@ -19,6 +19,7 @@ import { PluginsSection } from "@/components/admin/settings/sections/plugins-sec
import { NextcloudSection } from "@/components/admin/settings/sections/nextcloud-section"
import { MailingSection } from "@/components/admin/settings/sections/mailing-section"
import { OnlyofficeSection } from "@/components/admin/settings/sections/onlyoffice-section"
import { RichtextSection } from "@/components/admin/settings/sections/richtext-section"
import { AuditSection } from "@/components/admin/settings/sections/audit-section"
const SECTIONS: Record<AdminSettingsSectionId, React.ComponentType> = {
@ -36,6 +37,7 @@ const SECTIONS: Record<AdminSettingsSectionId, React.ComponentType> = {
nextcloud: NextcloudSection,
mailing: MailingSection,
onlyoffice: OnlyofficeSection,
richtext: RichtextSection,
audit: AuditSection,
}

View File

@ -0,0 +1,89 @@
"use client"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Input } from "@/components/ui/input"
export function RichtextSection() {
const richtext = useOrgSettingsStore((s) => s.richtext)
const setRichtext = useOrgSettingsStore((s) => s.setRichtext)
return (
<OrgSettingsSection
title="Éditeur rich text"
description="TipTap pour les documents texte (docx, odt, md…). OnlyOffice reste actif pour tableurs et présentations."
policySection="richtext"
>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-4">
<div>
<CardTitle className="text-sm font-medium">TipTap + Hocuspocus</CardTitle>
<CardDescription>
Formats word via l&apos;éditeur rich text ; sauvegarde en .ultidoc.json dans Nextcloud.
</CardDescription>
</div>
<Switch
checked={richtext.enabled}
onCheckedChange={(enabled) => setRichtext({ enabled })}
/>
</div>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Mode de stockage</Label>
<Select
value={richtext.storage_mode}
onValueChange={(storage_mode: "sidecar" | "overwrite") =>
setRichtext({ storage_mode })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sidecar">Sidecar (.ultidoc.json à côté de l&apos;original)</SelectItem>
<SelectItem value="overwrite">Remplacer par .ultidoc.json</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Export miroir (optionnel)</Label>
<Select
value={richtext.export_mirror_format || "none"}
onValueChange={(v) =>
setRichtext({ export_mirror_format: v === "none" ? "" : "docx" })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Aucun</SelectItem>
<SelectItem value="docx">DOCX</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>URL WebSocket Hocuspocus (public)</Label>
<Input
value={richtext.hocuspocus_url}
onChange={(e) => setRichtext({ hocuspocus_url: e.target.value })}
placeholder="ws://localhost:1234"
/>
</div>
</CardContent>
</Card>
</OrgSettingsSection>
)
}

View File

@ -24,6 +24,7 @@ export function OfficeEditorChrome({
backHref,
backLabel,
title,
showBack = true,
onRename,
renameDisabled = false,
shares = [],
@ -32,9 +33,10 @@ export function OfficeEditorChrome({
showAccount = false,
trailing,
}: {
backHref: string
backLabel: string
backHref?: string
backLabel?: string
title: string
showBack?: boolean
onRename?: (next: string) => Promise<void>
renameDisabled?: boolean
shares?: DriveShare[]
@ -48,12 +50,14 @@ export function OfficeEditorChrome({
return (
<>
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3 ultidrive-editor-chrome">
<Button variant="ghost" size="sm" asChild className="shrink-0">
<Link href={backHref}>
<ArrowLeft className="mr-1 h-4 w-4" />
{backLabel}
</Link>
</Button>
{showBack && backHref ? (
<Button variant="ghost" size="sm" asChild className="shrink-0">
<Link href={backHref}>
<ArrowLeft className="mr-1 h-4 w-4" />
{backLabel ?? "Retour"}
</Link>
</Button>
) : null}
<div className="min-w-0 flex-1">
{onRename ? (

View File

@ -7,7 +7,9 @@ import { ArrowLeft } from "lucide-react"
import { OnlyOfficeMount } from "@/components/drive/onlyoffice-mount"
import { OfficeEditorChrome } from "@/components/drive/office-editor-chrome"
import { displayFileBaseName } from "@/lib/drive/display-file-name"
import { resolvePublicShareEditReturnTo } from "@/lib/drive/public-share-url"
import { getGuestEditorIdentity } from "@/lib/drive/guest-editor-identity"
import { resolvePublicShareEditReturnTo, shouldShowPublicShareEditorBack } from "@/lib/drive/public-share-url"
import type { PublicShareRootType } from "@/lib/drive/public-share-url"
import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title"
function fileNameFromPath(filePath: string, fallback?: string): string {
@ -22,6 +24,7 @@ export function PublicOfficeEditor({
returnTo,
mode = "edit",
fileDisplayName,
shareRoot,
}: {
token: string
filePath: string
@ -29,13 +32,10 @@ export function PublicOfficeEditor({
returnTo?: string | null
mode?: "edit" | "view"
fileDisplayName?: string
shareRoot?: PublicShareRootType | null
}) {
const instanceSeq = useRef(0)
const guestId = useRef(
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `guest-${Date.now()}`
)
const guest = useMemo(() => getGuestEditorIdentity(token), [token])
const [config, setConfig] = useState<Record<string, unknown> | null>(null)
const [serverUrl, setServerUrl] = useState("")
const [editorId, setEditorId] = useState<string | null>(null)
@ -50,6 +50,7 @@ export function PublicOfficeEditor({
() => resolvePublicShareEditReturnTo(token, returnTo, filePath),
[token, returnTo, filePath]
)
const showBack = shouldShowPublicShareEditorBack(shareRoot, returnTo, filePath)
useEffect(() => {
let cancelled = false
@ -70,7 +71,8 @@ export function PublicOfficeEditor({
path: filePath,
mode,
password: password ?? "",
guest_id: guestId.current,
guest_id: guest.guestId,
guest_name: guest.guestName,
}),
}
)
@ -111,12 +113,14 @@ export function PublicOfficeEditor({
return (
<div className="flex h-dvh flex-col items-center justify-center gap-4">
<p className="text-destructive">{error}</p>
<Button asChild variant="outline">
<Link href={backHref}>
<ArrowLeft className="mr-2 h-4 w-4" />
Retour
</Link>
</Button>
{showBack ? (
<Button asChild variant="outline">
<Link href={backHref}>
<ArrowLeft className="mr-2 h-4 w-4" />
Retour
</Link>
</Button>
) : null}
</div>
)
}
@ -130,6 +134,7 @@ export function PublicOfficeEditor({
<OfficeEditorChrome
backHref={backHref}
backLabel="Partage"
showBack={showBack}
title={title}
trailing={
resolvedMode === "view" ? (

View File

@ -0,0 +1,202 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { ArrowLeft, Loader2 } from "lucide-react"
import { OfficeEditorChrome } from "@/components/drive/office-editor-chrome"
import { RichTextDocumentEditor } from "@/components/drive/richtext-document"
import { displayFileBaseName } from "@/lib/drive/display-file-name"
import { resolvePublicShareEditReturnTo, shouldShowPublicShareEditorBack } from "@/lib/drive/public-share-url"
import type { PublicShareRootType } from "@/lib/drive/public-share-url"
import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title"
import { getGuestEditorIdentity } from "@/lib/drive/guest-editor-identity"
import type { RichTextSaveStatus, RichTextSessionResponse } from "@/lib/drive/richtext-types"
import { fetchPublicShareBlob } from "@/lib/api/public-share"
import { cn } from "@/lib/utils"
function fileNameFromPath(filePath: string, fallback?: string): string {
const base = filePath.split("/").filter(Boolean).pop()
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({
token,
filePath,
password,
returnTo,
mode = "edit",
fileDisplayName,
shareRoot,
}: {
token: string
filePath: string
password?: string
returnTo?: string | null
mode?: "edit" | "view"
fileDisplayName?: string
shareRoot?: PublicShareRootType | null
}) {
const guest = useMemo(() => getGuestEditorIdentity(token), [token])
const [session, setSession] = useState<RichTextSessionResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const [saveStatus, setSaveStatus] = useState<RichTextSaveStatus>("idle")
const [resolvedMode, setResolvedMode] = useState<"edit" | "view">(mode)
const fileName = fileDisplayName || fileNameFromPath(filePath)
const title = displayFileBaseName(fileName)
useDriveDocumentTitle(title)
const backHref = useMemo(
() => resolvePublicShareEditReturnTo(token, returnTo, filePath),
[token, returnTo, filePath]
)
const showBack = shouldShowPublicShareEditorBack(shareRoot, returnTo, filePath)
useEffect(() => {
let cancelled = false
setSession(null)
setError(null)
void (async () => {
try {
const res = await fetch(
`/api/v1/drive/public/shares/${encodeURIComponent(token)}/richtext/session`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: filePath,
mode,
password: password ?? "",
guest_id: guest.guestId,
guest_name: guest.guestName,
display_name: fileName,
}),
}
)
if (!res.ok) throw new Error("Session indisponible")
const data = (await res.json()) as RichTextSessionResponse
if (!cancelled) {
setSession(data)
setResolvedMode(data.mode === "view" ? "view" : mode)
}
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Impossible d'ouvrir le document")
}
})()
return () => {
cancelled = true
}
}, [token, filePath, mode, password, guest.guestId, guest.guestName, fileName])
const fetchSourceBytes = useCallback(
async (path: string) => {
const blob = await fetchPublicShareBlob(
token,
{ path, name: path.split("/").pop() ?? path },
password
)
return blob.arrayBuffer()
},
[token, password]
)
const importApi = useCallback(
async (body: { source_path: string; content: Record<string, unknown> }) => {
const res = await fetch(
`/api/v1/drive/public/shares/${encodeURIComponent(token)}/richtext/import`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...body, password: password ?? "", display_name: fileName }),
}
)
if (!res.ok) throw new Error("Import impossible")
},
[token, password, fileName]
)
const statusText = saveStatusLabel(saveStatus)
if (error) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8">
<p className="text-sm text-muted-foreground">{error}</p>
{showBack ? (
<Button variant="outline" asChild>
<Link href={backHref}>
<ArrowLeft className="mr-2 h-4 w-4" />
Retour
</Link>
</Button>
) : null}
</div>
)
}
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<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 ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<RichTextDocumentEditor
session={session}
mode={resolvedMode}
userName={guest.guestName}
userColor={guest.color}
onSaveStatus={setSaveStatus}
fetchSourceBytes={fetchSourceBytes}
importApi={importApi}
/>
)}
</div>
</div>
)
}

View File

@ -21,14 +21,15 @@ import type { DriveFileInfo } from "@/lib/api/types"
import { drivePreviewKind, isSvgFile } from "@/lib/drive/drive-preview"
import { SvgPreviewViewer } from "@/components/drive/svg-preview-viewer"
import { PUBLIC_SHARE_INSET_X } from "@/lib/drive/drive-chrome-classes"
import { shouldOpenInOnlyOffice } from "@/lib/drive/drive-preview"
import { shouldOpenInOnlyOffice, shouldOpenInRichTextEditor } from "@/lib/drive/drive-preview"
import {
sharePermCanEdit,
} from "@/lib/drive/drive-share-permissions"
import { buildPublicShareEditHref } from "@/lib/drive/public-share-url"
import { buildPublicShareEditHref, persistPublicShareRootType } from "@/lib/drive/public-share-url"
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
import { cn } from "@/lib/utils"
import { filterHiddenDriveSidecars } from "@/lib/drive/drive-hidden-files"
const PdfPreviewViewer = dynamic(
() => import("@/components/drive/pdf-preview-viewer").then((m) => m.PdfPreviewViewer),
@ -214,28 +215,37 @@ export function PublicShareViewPanel({
}) {
const router = useRouter()
const file = data.item_type === "file" ? data.file : null
const files = data.item_type === "folder" ? (data.files ?? []) : []
const files = data.item_type === "folder" ? filterHiddenDriveSidecars(data.files ?? []) : []
const rootShareName = usePublicShareRootName(token, path, data.name)
const sharedByLabel = publicShareOwnerLabel(data)
const permissions = data.permissions ?? 1
const canEdit = sharePermCanEdit(permissions)
useEffect(() => {
if (!file || !shouldOpenInOnlyOffice(file)) return
persistPublicShareRootType(token, data.item_type)
}, [token, data.item_type])
useEffect(() => {
if (!file) return
const isEditorFile = shouldOpenInRichTextEditor(file) || shouldOpenInOnlyOffice(file)
if (!isEditorFile) return
const returnTo =
typeof window !== "undefined"
? window.location.pathname + window.location.search
: undefined
const editor = shouldOpenInRichTextEditor(file) ? "richtext" : "office"
router.replace(
buildPublicShareEditHref(
token,
file.path,
returnTo,
canEdit ? "edit" : "view",
file.name
file.name,
editor,
data.item_type
)
)
}, [canEdit, file, router, token])
}, [canEdit, data.item_type, file, router, token])
const downloadCurrent = () => {
if (!file) return
@ -249,7 +259,7 @@ export function PublicShareViewPanel({
anchor.remove()
}
if (file && shouldOpenInOnlyOffice(file)) {
if (file && (shouldOpenInRichTextEditor(file) || shouldOpenInOnlyOffice(file))) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />

View File

@ -0,0 +1,235 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useEditor, EditorContent } from "@tiptap/react"
import { HocuspocusProvider } from "@hocuspocus/provider"
import * as Y from "yjs"
import { buildRichTextExtensions, RICHTEXT_EDITOR_CLASS } from "@/lib/drive/richtext-extensions"
import type { RichTextSaveStatus, RichTextSessionResponse } from "@/lib/drive/richtext-types"
import { apiClient } from "@/lib/api/client"
import { driveDownloadApiPath } from "@/lib/api/drive-download"
import { importFileToTipTap } from "@/lib/drive/richtext-import"
import { RichTextToolbar } from "@/components/drive/richtext-toolbar"
const SAVE_DEBOUNCE_MS = 2000
/** Align with Hocuspocus store debounce + buffer */
const COLLAB_SAVE_IDLE_MS = 2000
export function RichTextDocumentEditor({
session,
mode,
userName,
userColor,
onSaveStatus,
fetchSourceBytes,
importApi,
}: {
session: RichTextSessionResponse
mode: "edit" | "view"
userName: string
userColor: string
onSaveStatus?: (status: RichTextSaveStatus) => void
fetchSourceBytes?: (path: string) => Promise<ArrayBuffer>
importApi?: (body: { source_path: string; content: Record<string, unknown> }) => Promise<void>
}) {
const editable = mode === "edit"
const collaboration = session.collaboration && Boolean(session.wsUrl && session.token)
const ydocRef = useRef<Y.Doc | null>(null)
if (collaboration && !ydocRef.current) {
ydocRef.current = new Y.Doc()
}
const ydoc = collaboration ? ydocRef.current : null
const [provider, setProvider] = useState<HocuspocusProvider | null>(null)
const [collabSynced, setCollabSynced] = useState(false)
const [collabError, setCollabError] = useState<string | null>(null)
const [importDone, setImportDone] = useState(!session.importRequired)
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const saveIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const markCollabDirty = useCallback(() => {
onSaveStatus?.("saving")
if (saveIdleTimer.current) clearTimeout(saveIdleTimer.current)
saveIdleTimer.current = setTimeout(() => {
onSaveStatus?.("saved")
}, COLLAB_SAVE_IDLE_MS)
}, [onSaveStatus])
useEffect(() => {
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current)
if (saveIdleTimer.current) clearTimeout(saveIdleTimer.current)
}
}, [])
useEffect(() => {
if (!session.importRequired || importDone) return
let cancelled = false
void (async () => {
onSaveStatus?.("saving")
try {
const source = session.sourcePath || session.canonicalPath
const buf = fetchSourceBytes
? await fetchSourceBytes(source)
: await (await apiClient.getBlob(driveDownloadApiPath(source))).arrayBuffer()
const content = await importFileToTipTap(source.split("/").pop() ?? "file.docx", buf)
if (cancelled) return
const payload = { source_path: source, content }
if (importApi) {
await importApi(payload)
} else {
await apiClient.post("/richtext/import", payload)
}
if (!cancelled) {
setImportDone(true)
onSaveStatus?.("saved")
}
} catch {
if (!cancelled) onSaveStatus?.("error")
}
})()
return () => {
cancelled = true
}
}, [session, importDone, fetchSourceBytes, importApi, onSaveStatus])
useEffect(() => {
if (!collaboration || !ydoc || !importDone) return
setCollabSynced(false)
setCollabError(null)
const p = new HocuspocusProvider({
url: session.wsUrl,
name: session.roomId,
token: session.token,
document: ydoc,
sessionAwareness: false,
onSynced: () => setCollabSynced(true),
onAuthenticationFailed: ({ reason }) => {
setCollabError(reason ?? "Authentification collaboration refusée")
setCollabSynced(false)
},
})
setProvider(p)
return () => {
p.destroy()
setProvider(null)
setCollabSynced(false)
}
}, [collaboration, importDone, session.roomId, session.token, session.wsUrl, ydoc])
const scheduleSave = useCallback(
(json: Record<string, unknown>) => {
if (!editable || collaboration) return
if (saveTimer.current) clearTimeout(saveTimer.current)
onSaveStatus?.("saving")
saveTimer.current = setTimeout(() => {
const doc = { schemaVersion: 1, editor: "tiptap", content: json }
const body = JSON.stringify(doc)
const savePromise = session.saveUrl
? fetch(session.saveUrl, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body,
}).then((res) => {
if (!res.ok) throw new Error("save failed")
})
: apiClient.put("/richtext/save", { path: session.canonicalPath, document: json })
void savePromise
.then(() => onSaveStatus?.("saved"))
.catch(() => onSaveStatus?.("error"))
}, SAVE_DEBOUNCE_MS)
},
[collaboration, editable, onSaveStatus, session.canonicalPath, session.saveUrl]
)
const collabReady = !collaboration || (Boolean(provider) && collabSynced)
const editorEnabled = importDone && collabReady
const extensions = useMemo(
() =>
buildRichTextExtensions({
collaboration: collaboration && ydoc ? { document: ydoc } : undefined,
collaborationCaret:
collaboration && provider
? { provider, user: { name: userName, color: userColor } }
: undefined,
editable,
}),
[collaboration, ydoc, provider, userName, userColor, editable]
)
const editor = useEditor(
{
immediatelyRender: false,
editable,
extensions,
editorProps: { attributes: { class: RICHTEXT_EDITOR_CLASS } },
onUpdate: ({ editor: ed }) => {
if (collaboration) {
markCollabDirty()
return
}
scheduleSave(ed.getJSON() as Record<string, unknown>)
},
},
[editorEnabled, extensions, collaboration, markCollabDirty, scheduleSave]
)
useEffect(() => {
if (!editor || collaboration || !importDone || session.importRequired) return
let cancelled = false
void (async () => {
try {
let parsed: { content?: Record<string, unknown> }
if (session.documentUrl) {
const res = await fetch(session.documentUrl)
if (!res.ok) throw new Error("load failed")
parsed = JSON.parse(await res.text()) as { content?: Record<string, unknown> }
} else {
const blob = await apiClient.getBlob(driveDownloadApiPath(session.canonicalPath))
parsed = JSON.parse(await blob.text()) as { content?: Record<string, unknown> }
}
if (!cancelled && parsed.content) editor.commands.setContent(parsed.content)
} catch {
/* blank */
}
})()
return () => {
cancelled = true
}
}, [editor, collaboration, importDone, session.canonicalPath, session.documentUrl, session.importRequired])
if (collabError) {
return (
<div className="flex h-full items-center justify-center p-6 text-sm text-destructive">
Collaboration indisponible : {collabError}
</div>
)
}
if (!editorEnabled || !editor) {
const statusText =
session.importRequired && !importDone
? "Import du document…"
: collaboration && !collabSynced
? "Connexion à la collaboration…"
: "Connexion…"
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{statusText}
</div>
)
}
return (
<div className="flex h-full min-h-0 flex-col">
{editable ? <RichTextToolbar editor={editor} /> : null}
<div className="min-h-0 flex-1 overflow-auto">
<EditorContent editor={editor} className="h-full" />
</div>
</div>
)
}

View File

@ -0,0 +1,176 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { apiClient } from "@/lib/api/client"
import { Button } from "@/components/ui/button"
import { ArrowLeft, Loader2 } from "lucide-react"
import { OfficeEditorChrome } from "@/components/drive/office-editor-chrome"
import { RichTextDocumentEditor } from "@/components/drive/richtext-document"
import { useDriveMutations, useDriveShares } from "@/lib/api/hooks/use-drive-queries"
import { displayFileBaseName } from "@/lib/drive/display-file-name"
import { resolveRenameName } from "@/lib/drive/drive-default-name"
import { driveFolderHref } from "@/lib/drive/drive-sidebar-tree"
import { buildDriveEditHref, resolveDriveEditReturnTo } from "@/lib/drive/drive-url"
import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
import { colorForGuestId } from "@/lib/drive/guest-editor-identity"
import type { RichTextSaveStatus, RichTextSessionResponse } from "@/lib/drive/richtext-types"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { cn } from "@/lib/utils"
function fileNameFromPath(filePath: string): string {
return filePath.split("/").filter(Boolean).pop() ?? filePath
}
function renameTargetPath(filePath: string, newName: string): string {
const parent = filePath.replace(/\/[^/]+$/, "") || "/"
const base = parent === "/" ? "" : parent
return `${base}/${newName}`.replace(/\/+/g, "/") || `/${newName}`
}
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 RichTextEditor({
filePath,
returnTo,
}: {
filePath: string
returnTo?: string | null
}) {
const router = useRouter()
const identity = useChromeIdentity()
const setSharePath = useDriveUIStore((s) => s.setSharePath)
const [session, setSession] = useState<RichTextSessionResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const [saveStatus, setSaveStatus] = useState<RichTextSaveStatus>("idle")
const [displayPath, setDisplayPath] = useState(filePath)
useEffect(() => {
setDisplayPath(filePath)
}, [filePath])
const fileName = fileNameFromPath(displayPath)
const title = displayFileBaseName(fileName)
useDriveDocumentTitle(title)
const backHref = useMemo(
() =>
resolveDriveEditReturnTo(returnTo, displayPath, (folderPath) =>
driveFolderHref("files", folderPath)
),
[returnTo, displayPath]
)
const { data: sharesData } = useDriveShares(displayPath, Boolean(displayPath))
const { rename } = useDriveMutations()
useEffect(() => {
let cancelled = false
setSession(null)
setError(null)
void (async () => {
try {
const res = await apiClient.post<RichTextSessionResponse>("/richtext/session", {
path: displayPath,
mode: "edit",
})
if (!cancelled) setSession(res)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Impossible d'ouvrir le document")
}
})()
return () => {
cancelled = true
}
}, [displayPath])
const handleRename = useCallback(
async (input: string) => {
const newName = resolveRenameName({ name: fileName, type: "file" }, input)
if (displayFileBaseName(fileName) === input.trim()) return
await rename.mutateAsync({ path: displayPath, new_name: newName })
const nextPath = renameTargetPath(displayPath, newName)
setDisplayPath(nextPath)
router.replace(buildDriveEditHref(nextPath, returnTo ?? undefined, "richtext"))
},
[displayPath, fileName, rename, returnTo, router]
)
const openShare = useCallback(() => {
setSharePath(displayPath)
}, [displayPath, setSharePath])
const statusText = saveStatusLabel(saveStatus)
const collabUserName = identity?.name?.trim() || identity?.email || "Utilisateur"
const collabUserColor = colorForGuestId(identity?.email ?? collabUserName)
if (error) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8">
<p className="text-sm text-muted-foreground">{error}</p>
<Button variant="outline" asChild>
<Link href={backHref}>
<ArrowLeft className="mr-2 h-4 w-4" />
Retour
</Link>
</Button>
</div>
)
}
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<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 ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<RichTextDocumentEditor
session={session}
mode="edit"
userName={collabUserName}
userColor={collabUserColor}
onSaveStatus={setSaveStatus}
/>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,97 @@
"use client"
import { Bold, Italic, List, ListOrdered, Redo, Undo, Underline as UnderlineIcon } from "lucide-react"
import type { Editor } from "@tiptap/react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
export function RichTextToolbar({ editor, disabled }: { editor: Editor | null; disabled?: boolean }) {
if (!editor) return null
return (
<div className="flex shrink-0 flex-wrap items-center gap-0.5 border-b border-border px-2 py-1.5">
<ToolbarButton
disabled={disabled}
active={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleMark("bold").run()}
label="Gras"
>
<Bold className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
disabled={disabled}
active={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleMark("italic").run()}
label="Italique"
>
<Italic className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
disabled={disabled}
active={editor.isActive("underline")}
onClick={() => editor.chain().focus().toggleUnderline().run()}
label="Souligné"
>
<UnderlineIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
disabled={disabled}
active={editor.isActive("bulletList")}
onClick={() => editor.chain().focus().toggleBulletList().run()}
label="Liste"
>
<List className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
disabled={disabled}
active={editor.isActive("orderedList")}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
label="Liste numérotée"
>
<ListOrdered className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
disabled={disabled || !editor.can().chain().focus().undo().run()}
onClick={() => editor.chain().focus().undo().run()}
label="Annuler"
>
<Undo className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
disabled={disabled || !editor.can().chain().focus().redo().run()}
onClick={() => editor.chain().focus().redo().run()}
label="Rétablir"
>
<Redo className="h-4 w-4" />
</ToolbarButton>
</div>
)
}
function ToolbarButton({
children,
onClick,
active,
disabled,
label,
}: {
children: React.ReactNode
onClick: () => void
active?: boolean
disabled?: boolean
label: string
}) {
return (
<Button
type="button"
variant="ghost"
size="icon"
className={cn("h-8 w-8", active && "bg-accent")}
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
>
{children}
</Button>
)
}

View File

@ -0,0 +1,41 @@
import { test, expect } from "@playwright/test"
import {
colorForGuestId,
generateGuestDisplayName,
isReadableCursorColor,
} from "../lib/drive/guest-editor-identity"
import {
isRichTextFile,
sidecarPathForSource,
} from "../lib/drive/richtext-formats"
test.describe("rich text editor helpers", () => {
test("guest display name uses adjective and animal", () => {
const name = generateGuestDisplayName()
const parts = name.split(" ")
expect(parts.length).toBe(2)
expect(parts[0]?.length).toBeGreaterThan(0)
expect(parts[1]?.length).toBeGreaterThan(0)
})
test("guest color is stable for same id", () => {
const a = colorForGuestId("guest-abc")
const b = colorForGuestId("guest-abc")
expect(a).toBe(b)
})
test("cursor colors are readable with white label text", () => {
for (let i = 0; i < 20; i++) {
expect(isReadableCursorColor(colorForGuestId(`guest-${i}`))).toBe(true)
}
})
test("word formats route to rich text", () => {
expect(isRichTextFile({ name: "rapport.docx" })).toBe(true)
expect(isRichTextFile({ name: "budget.xlsx" })).toBe(false)
})
test("sidecar path preserves folder and basename", () => {
expect(sidecarPathForSource("/docs/rapport.docx")).toBe("/docs/rapport.ultidoc.json")
})
})

View File

@ -128,6 +128,12 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
nextcloud: { ...policy.nextcloud },
mailing: { ...policy.mailing },
onlyoffice: { ...policy.onlyoffice },
richtext: {
enabled: policy.richtext?.enabled ?? true,
storage_mode: policy.richtext?.storage_mode ?? "sidecar",
export_mirror_format: policy.richtext?.export_mirror_format ?? "",
hocuspocus_url: policy.richtext?.hocuspocus_url ?? "",
},
plugins: policy.plugins ?? [],
integrations: mergeIntegrations(policy.integrations as IntegrationEntry[]),
}
@ -180,6 +186,7 @@ export function storeToApiOrgPolicy(state: OrgSettingsState): ApiOrgPolicy {
nextcloud: { ...state.nextcloud },
mailing: { ...state.mailing },
onlyoffice: { ...state.onlyoffice },
richtext: { ...state.richtext },
plugins: state.plugins.map(({ id, name, description, enabled, version }) => ({
id,
name,

View File

@ -10,6 +10,7 @@ import type {
MailingSettings,
NextcloudSettings,
OnlyOfficeSettings,
RichTextSettings,
OrgLLMSettings,
OrgSearchSettings,
OrgStorageQuotas,
@ -115,6 +116,13 @@ const DEFAULT_ONLYOFFICE: OnlyOfficeSettings = {
jwt_header: "Authorization",
}
const DEFAULT_RICHTEXT: RichTextSettings = {
enabled: true,
storage_mode: "sidecar",
export_mirror_format: "",
hocuspocus_url: "",
}
const DEFAULT_PLUGINS: PluginEntry[] = [
{
id: "mail-automation",
@ -144,6 +152,13 @@ const DEFAULT_PLUGINS: PluginEntry[] = [
enabled: false,
version: "1.0.0",
},
{
id: "richtext-editor",
name: "Édition rich text TipTap",
description: "Éditeur texte collaboratif pour documents Word.",
enabled: true,
version: "1.0.0",
},
]
const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
@ -193,6 +208,7 @@ type OrgSettingsActions = {
setNextcloud: (patch: Partial<NextcloudSettings>) => void
setMailing: (patch: Partial<MailingSettings>) => void
setOnlyoffice: (patch: Partial<OnlyOfficeSettings>) => void
setRichtext: (patch: Partial<RichTextSettings>) => void
setAdministrators: (admins: Administrator[]) => void
addAdministrator: (admin: Administrator) => void
removeAdministrator: (id: string) => void
@ -214,6 +230,7 @@ type OrgSettingsActions = {
nextcloud: NextcloudSettings
mailing: MailingSettings
onlyoffice: OnlyOfficeSettings
richtext: RichTextSettings
plugins: PluginEntry[]
integrations: IntegrationEntry[]
}>, meta?: OrgSettingsMeta) => void
@ -233,6 +250,7 @@ export const useOrgSettingsStore = create<
nextcloud: NextcloudSettings
mailing: MailingSettings
onlyoffice: OnlyOfficeSettings
richtext: RichTextSettings
plugins: PluginEntry[]
integrations: IntegrationEntry[]
meta: OrgSettingsMeta | null
@ -251,6 +269,7 @@ export const useOrgSettingsStore = create<
nextcloud: DEFAULT_NEXTCLOUD,
mailing: DEFAULT_MAILING,
onlyoffice: DEFAULT_ONLYOFFICE,
richtext: DEFAULT_RICHTEXT,
plugins: DEFAULT_PLUGINS,
integrations: DEFAULT_INTEGRATIONS,
meta: null,
@ -278,6 +297,8 @@ export const useOrgSettingsStore = create<
set((s) => ({ mailing: { ...s.mailing, ...patch } })),
setOnlyoffice: (patch) =>
set((s) => ({ onlyoffice: { ...s.onlyoffice, ...patch } })),
setRichtext: (patch) =>
set((s) => ({ richtext: { ...s.richtext, ...patch } })),
setAdministrators: (administrators) => set({ administrators }),
addAdministrator: (admin) =>
set((s) => ({ administrators: [...s.administrators, admin] })),

View File

@ -165,6 +165,13 @@ export type OnlyOfficeSettings = {
jwt_header: string
}
export type RichTextSettings = {
enabled: boolean
storage_mode: "sidecar" | "overwrite"
export_mirror_format: "" | "docx"
hocuspocus_url: string
}
export type PluginEntry = {
id: string
name: string
@ -195,6 +202,7 @@ export type OrgSettingsState = {
nextcloud: NextcloudSettings
mailing: MailingSettings
onlyoffice: OnlyOfficeSettings
richtext: RichTextSettings
plugins: PluginEntry[]
integrations: IntegrationEntry[]
}

View File

@ -4,6 +4,7 @@ import {
Bot,
Cloud,
FileCog,
FileText,
Gauge,
HardDrive,
Link2,
@ -32,6 +33,7 @@ export type AdminSettingsSectionId =
| "nextcloud"
| "mailing"
| "onlyoffice"
| "richtext"
| "audit"
export type AdminSettingsNavItem = {
@ -141,6 +143,13 @@ export const ADMIN_SETTINGS_NAV: AdminSettingsNavItem[] = [
href: "/admin/settings/onlyoffice",
icon: Activity,
},
{
id: "richtext",
label: "Éditeur rich text",
description: "TipTap pour documents texte",
href: "/admin/settings/richtext",
icon: FileText,
},
{
id: "audit",
label: "Journal d'audit",

View File

@ -152,6 +152,13 @@ export type ApiOrgOnlyoffice = {
jwt_header: string
}
export type ApiOrgRichText = {
enabled: boolean
storage_mode: "sidecar" | "overwrite"
export_mirror_format: "" | "docx"
hocuspocus_url: string
}
export type ApiOrgPlugin = {
id: string
name: string
@ -181,6 +188,7 @@ export type ApiOrgPolicy = {
nextcloud: ApiOrgNextcloud
mailing: ApiOrgMailing
onlyoffice: ApiOrgOnlyoffice
richtext?: ApiOrgRichText
plugins: ApiOrgPlugin[]
integrations: ApiOrgIntegration[]
}

View File

@ -0,0 +1,49 @@
import type { DriveFileInfo } from "@/lib/api/types"
import { isUltidocPath } from "@/lib/drive/richtext-formats"
function normalizePath(path: string): string {
const p = path.replace(/\/+/g, "/")
if (!p.startsWith("/")) return `/${p}`
return p
}
function fileName(path: string): string {
const normalized = normalizePath(path)
const slash = normalized.lastIndexOf("/")
return slash >= 0 ? normalized.slice(slash + 1) : normalized
}
function parentDir(path: string): string {
const normalized = normalizePath(path)
if (normalized === "/") return "/"
const slash = normalized.lastIndexOf("/")
return slash <= 0 ? "/" : normalized.slice(0, slash)
}
function documentBase(name: string): string {
if (isUltidocPath(name)) {
return name.slice(0, -(`.ultidoc.json`.length))
}
const dot = name.lastIndexOf(".")
return dot > 0 ? name.slice(0, dot) : name
}
function sidecarSourceKey(path: string): string {
return `${parentDir(path)}\0${documentBase(fileName(path)).toLowerCase()}`
}
/** Hide .ultidoc.json sidecars when their source file is in the same folder listing. */
export function filterHiddenDriveSidecars(items: DriveFileInfo[]): DriveFileInfo[] {
if (items.length === 0) return items
const sources = new Set<string>()
for (const item of items) {
if (item.type === "directory" || isUltidocPath(item.name)) continue
sources.add(sidecarSourceKey(item.path))
}
return items.filter((item) => {
if (item.type === "directory" || !isUltidocPath(item.name)) return true
return !sources.has(sidecarSourceKey(item.path))
})
}

View File

@ -4,6 +4,7 @@ import {
drivePreviewKind,
isPreviewNavigable,
shouldOpenInOnlyOffice,
shouldOpenInRichTextEditor,
toPreviewTarget,
} from "@/lib/drive/drive-preview"
import { driveFolderHref } from "@/lib/drive/drive-sidebar-tree"
@ -44,11 +45,25 @@ export function openDriveItem(file: DriveFileInfo, options: OpenDriveItemOptions
}
if (drivePreviewKind(file)) {
const navigable = contextItems
.filter((item) => isPreviewNavigable(item))
.map((item) => toPreviewTarget(item))
const index = navigable.findIndex((item) => item.path === file.path)
openPreview(navigable, index >= 0 ? index : 0, { allowShare, isTrash })
const ext = file.name.split(".").pop()?.toLowerCase() ?? ""
const richTextPreview =
ext === "md" || ext === "markdown" || ext === "txt" || ext === "html" || ext === "htm"
if (!richTextPreview) {
const navigable = contextItems
.filter((item) => isPreviewNavigable(item))
.map((item) => toPreviewTarget(item))
const index = navigable.findIndex((item) => item.path === file.path)
openPreview(navigable, index >= 0 ? index : 0, { allowShare, isTrash })
return
}
}
if (shouldOpenInRichTextEditor(file)) {
const returnTo =
typeof window !== "undefined"
? window.location.pathname + window.location.search
: undefined
router.push(buildDriveEditHref(file.path, returnTo, "richtext"))
return
}

View File

@ -1,5 +1,6 @@
import type { DriveFileInfo } from "@/lib/api/types"
import { isOnlyOfficeFile } from "@/lib/drive/onlyoffice-formats"
import { isOnlyOfficeFile, isOnlyOfficeExtension, fileExtension as officeFileExtension } from "@/lib/drive/onlyoffice-formats"
import { isRichTextFile, isUltidocPath } from "@/lib/drive/richtext-formats"
export type DrivePreviewKind = "image" | "video" | "audio" | "pdf" | "text"
@ -72,15 +73,34 @@ export function isOfficeFormat(file: { name: string; mime_type?: string }): bool
return isOnlyOfficeFile(file)
}
/** Open in OnlyOffice when supported, except native preview types (image, video, PDF). */
/** Word-processor formats edited in TipTap (when rich-text editor is enabled). */
export function shouldOpenInRichTextEditor(file: {
name: string
mime_type?: string
type?: string
}): boolean {
if (file.type === "directory") return false
if (isUltidocPath(file.name)) return true
if (drivePreviewKind(file) === "text") {
const ext = officeFileExtension(file.name)
return ext === "md" || ext === "markdown" || ext === "txt" || ext === "html" || ext === "htm"
}
if (drivePreviewKind(file)) return false
return isRichTextFile(file)
}
/** Open in OnlyOffice for cell/slide/diagram only (word formats use TipTap). */
export function shouldOpenInOnlyOffice(file: {
name: string
mime_type?: string
type?: string
}): boolean {
if (file.type === "directory") return false
if (shouldOpenInRichTextEditor(file)) return false
if (drivePreviewKind(file)) return false
return isOfficeFormat(file)
if (!isOfficeFormat(file)) return false
const ext = officeFileExtension(file.name)
return isOnlyOfficeExtension(ext) && !isRichTextFile(file)
}
export function drivePreviewKind(file: {

View File

@ -146,10 +146,19 @@ function isSafeDriveReturnPath(path: string): boolean {
return true
}
export function buildDriveEditHref(filePath: string, returnTo?: string): string {
export function buildDriveEditHref(
filePath: string,
returnTo?: string,
editor: "office" | "richtext" = "office"
): string {
const params = new URLSearchParams()
if (editor === "richtext") params.set("editor", "richtext")
if (returnTo && isSafeDriveReturnPath(returnTo)) {
params.set("returnTo", returnTo)
}
const base = `/drive/edit/${encodeURIComponent(filePath)}`
if (!returnTo || !isSafeDriveReturnPath(returnTo)) return base
return `${base}?returnTo=${encodeURIComponent(returnTo)}`
const qs = params.toString()
return qs ? `${base}?${qs}` : base
}
/** Resolve back link from editor: prefer explicit returnTo, else parent folder. */

View File

@ -0,0 +1,153 @@
const ADJECTIVES_FR = [
"Agile",
"Brave",
"Calme",
"Curieux",
"Discret",
"Elegant",
"Fidele",
"Gentil",
"Habile",
"Joyeux",
"Leger",
"Malin",
"Noble",
"Paisible",
"Rapide",
"Sage",
"Timide",
"Vif",
"Zen",
"Zeste",
] as const
const ANIMALS_FR = [
"Lynx",
"Panda",
"Faucon",
"Loutre",
"Renard",
"Heron",
"Castor",
"Koala",
"Loup",
"Ours",
"Tigre",
"Aigle",
"Dauphin",
"Phoque",
"Ecureuil",
"Chouette",
"Cerf",
"Lama",
"Belette",
"Pieuvre",
] as const
/** Background colors for caret labels (white text — WCAG AA 4.5:1+). */
const CURSOR_COLORS = [
"#6D28D9", // violet
"#BE123C", // rose
"#C2410C", // orange
"#A16207", // gold (not light yellow)
"#1D4ED8", // blue
"#0F766E", // teal
"#15803D", // green
"#A21CAF", // fuchsia
"#4338CA", // indigo
"#B45309", // amber
] as const
const MIN_CURSOR_CONTRAST_WITH_WHITE = 4.5
function hexLuminance(hex: string): number {
const raw = hex.replace("#", "")
if (raw.length !== 6) return 1
const channel = (i: number) => {
const c = parseInt(raw.slice(i, i + 2), 16) / 255
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
}
return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4)
}
function contrastWithWhite(bgHex: string): number {
const bg = hexLuminance(bgHex)
return (1 + 0.05) / (bg + 0.05)
}
export function isReadableCursorColor(hex: string): boolean {
return contrastWithWhite(hex) >= MIN_CURSOR_CONTRAST_WITH_WHITE
}
function resolveCursorColor(id: string, stored?: string): string {
if (stored && isReadableCursorColor(stored)) return stored
return colorForGuestId(id)
}
export type GuestEditorIdentity = {
guestId: string
guestName: string
color: string
}
const STORAGE_PREFIX = "ultidrive-guest-identity:"
function pick<T>(items: readonly T[]): T {
return items[Math.floor(Math.random() * items.length)]!
}
function hashString(input: string): number {
let hash = 0
for (let i = 0; i < input.length; i++) {
hash = (hash << 5) - hash + input.charCodeAt(i)
hash |= 0
}
return Math.abs(hash)
}
export function generateGuestDisplayName(): string {
return `${pick(ADJECTIVES_FR)} ${pick(ANIMALS_FR)}`
}
export function colorForGuestId(guestId: string): string {
return CURSOR_COLORS[hashString(guestId) % CURSOR_COLORS.length]!
}
function newGuestId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID()
}
return `guest-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
export function getGuestEditorIdentity(shareToken: string): GuestEditorIdentity {
const key = `${STORAGE_PREFIX}${shareToken}`
if (typeof sessionStorage !== "undefined") {
const raw = sessionStorage.getItem(key)
if (raw) {
try {
const parsed = JSON.parse(raw) as GuestEditorIdentity
if (parsed.guestId && parsed.guestName) {
const color = resolveCursorColor(parsed.guestId, parsed.color)
return {
guestId: parsed.guestId,
guestName: parsed.guestName,
color,
}
}
} catch {
/* regenerate */
}
}
}
const guestId = newGuestId()
const identity: GuestEditorIdentity = {
guestId,
guestName: generateGuestDisplayName(),
color: colorForGuestId(guestId),
}
if (typeof sessionStorage !== "undefined") {
sessionStorage.setItem(key, JSON.stringify(identity))
}
return identity
}

View File

@ -3,6 +3,7 @@ import {
drivePreviewKind,
isPreviewNavigable,
shouldOpenInOnlyOffice,
shouldOpenInRichTextEditor,
toPreviewTarget,
} from "@/lib/drive/drive-preview"
import { fetchPublicShareBlob, publicShareDownloadApiPath, publicShareHref } from "@/lib/api/public-share"
@ -35,15 +36,32 @@ export function openPublicShareItem(file: DriveFileInfo, options: OpenPublicShar
}
if (drivePreviewKind(file)) {
const navigable = contextItems
.filter((item) => isPreviewNavigable(item))
.map((item) => toPreviewTarget(item))
const index = navigable.findIndex((item) => item.path === file.path)
openPreview(navigable, index >= 0 ? index : 0, {
allowShare: false,
isTrash: false,
publicShare: { token, password, canEdit },
})
const ext = file.name.split(".").pop()?.toLowerCase() ?? ""
const richTextPreview =
ext === "md" || ext === "markdown" || ext === "txt" || ext === "html" || ext === "htm"
if (!richTextPreview) {
const navigable = contextItems
.filter((item) => isPreviewNavigable(item))
.map((item) => toPreviewTarget(item))
const index = navigable.findIndex((item) => item.path === file.path)
openPreview(navigable, index >= 0 ? index : 0, {
allowShare: false,
isTrash: false,
publicShare: { token, password, canEdit },
})
return
}
}
if (shouldOpenInRichTextEditor(file)) {
const returnTo =
typeof window !== "undefined"
? window.location.pathname + window.location.search
: undefined
const mode = canEdit ? "edit" : "view"
router.push(
buildPublicShareEditHref(token, file.path, returnTo, mode, file.name, "richtext", "folder")
)
return
}
@ -53,7 +71,7 @@ export function openPublicShareItem(file: DriveFileInfo, options: OpenPublicShar
? window.location.pathname + window.location.search
: undefined
const mode = canEdit ? "edit" : "view"
router.push(buildPublicShareEditHref(token, file.path, returnTo, mode, file.name))
router.push(buildPublicShareEditHref(token, file.path, returnTo, mode, file.name, "office", "folder"))
return
}

View File

@ -1,9 +1,41 @@
export type PublicShareRootType = "file" | "folder"
export function publicShareRootTypeStorageKey(token: string): string {
return `public-share-root-type:${token}`
}
export function persistPublicShareRootType(token: string, rootType: PublicShareRootType): void {
if (typeof window === "undefined") return
sessionStorage.setItem(publicShareRootTypeStorageKey(token), rootType)
}
export function readPublicShareRootType(token: string): PublicShareRootType | null {
if (typeof window === "undefined") return null
const value = sessionStorage.getItem(publicShareRootTypeStorageKey(token))
return value === "file" || value === "folder" ? value : null
}
/** Hide back on single-file shares — no folder listing to return to. */
export function shouldShowPublicShareEditorBack(
rootType: PublicShareRootType | null | undefined,
returnTo?: string | null,
filePath?: string
): boolean {
if (rootType === "file") return false
if (rootType === "folder") return true
const path = (filePath ?? "/").replace(/^\/+|\/+$/g, "")
if (!path && !returnTo) return false
return true
}
export function buildPublicShareEditHref(
token: string,
filePath: string,
returnTo?: string,
mode: "edit" | "view" = "edit",
displayName?: string
displayName?: string,
editor: "office" | "richtext" = "office",
shareRoot?: PublicShareRootType
): string {
const trimmed = filePath.replace(/^\/+|\/+$/g, "")
const base = `/drive/s/${encodeURIComponent(token)}/edit/${trimmed.split("/").map(encodeURIComponent).join("/")}`
@ -14,9 +46,15 @@ export function buildPublicShareEditHref(
if (mode === "view") {
params.set("mode", "view")
}
if (editor === "richtext") {
params.set("editor", "richtext")
}
if (displayName?.trim()) {
params.set("name", displayName.trim())
}
if (shareRoot) {
params.set("shareRoot", shareRoot)
}
const qs = params.toString()
return qs ? `${base}?${qs}` : base
}

View File

@ -0,0 +1,71 @@
import type { Extensions } from "@tiptap/core"
import StarterKit from "@tiptap/starter-kit"
import Underline from "@tiptap/extension-underline"
import Link from "@tiptap/extension-link"
import TextAlign from "@tiptap/extension-text-align"
import { TextStyle, Color, BackgroundColor } from "@tiptap/extension-text-style"
import Highlight from "@tiptap/extension-highlight"
import Image from "@tiptap/extension-image"
import Placeholder from "@tiptap/extension-placeholder"
import { Table } from "@tiptap/extension-table"
import TableRow from "@tiptap/extension-table-row"
import TableCell from "@tiptap/extension-table-cell"
import TableHeader from "@tiptap/extension-table-header"
import Collaboration from "@tiptap/extension-collaboration"
import CollaborationCaret from "@tiptap/extension-collaboration-caret"
import type { HocuspocusProvider } from "@hocuspocus/provider"
import type * as Y from "yjs"
export function buildRichTextExtensions(options?: {
collaboration?: { document: Y.Doc }
collaborationCaret?: { provider: HocuspocusProvider; user: { name: string; color: string } }
placeholder?: string
editable?: boolean
}): Extensions {
const extensions: Extensions = [
StarterKit.configure({
undoRedo: options?.collaboration ? false : undefined,
}),
]
// Collaboration must register right after StarterKit (y-prosemirror binding).
if (options?.collaboration) {
extensions.push(
Collaboration.configure({
document: options.collaboration.document,
field: "default",
})
)
}
if (options?.collaborationCaret) {
extensions.push(
CollaborationCaret.configure({
provider: options.collaborationCaret.provider,
user: options.collaborationCaret.user,
})
)
}
extensions.push(
Underline,
Link.configure({ openOnClick: false }),
TextStyle,
Color,
BackgroundColor,
Highlight,
TextAlign.configure({ types: ["heading", "paragraph"], alignments: ["left", "center", "right", "justify"] }),
Table.configure({ resizable: true }),
TableRow,
TableCell,
TableHeader,
Image.configure({ inline: true, allowBase64: true }),
Placeholder.configure({
placeholder: options?.placeholder ?? "Commencez à écrire…",
})
)
return extensions
}
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"

View File

@ -0,0 +1,105 @@
/**
* Word-processor formats routed to the TipTap rich-text editor.
* Cell/slide/diagram formats stay on OnlyOffice.
*/
export const RICHTEXT_EXTENSIONS = new Set([
"doc",
"docm",
"docx",
"dot",
"dotm",
"dotx",
"epub",
"fb2",
"fodt",
"htm",
"html",
"hwp",
"hwpx",
"md",
"mht",
"mhtml",
"odt",
"ott",
"rtf",
"stw",
"sxw",
"txt",
"wps",
"wpt",
"xml",
"ultidoc",
] as const)
export const ULTIDOC_EXTENSION = "ultidoc.json"
const RICHTEXT_MIME_HINTS = [
"wordprocessingml",
"msword",
"opendocument.text",
"text/html",
"text/plain",
"text/markdown",
"application/rtf",
"application/epub",
] as const
export function fileExtension(name: string): string {
const base = name.split("/").pop() ?? name
const lower = base.toLowerCase()
if (lower.endsWith(`.${ULTIDOC_EXTENSION}`)) return ULTIDOC_EXTENSION
const i = base.lastIndexOf(".")
if (i <= 0) return ""
return base.slice(i + 1).toLowerCase()
}
export function isRichTextExtension(ext: string): boolean {
const normalized = ext.toLowerCase()
if (normalized === ULTIDOC_EXTENSION) return true
return RICHTEXT_EXTENSIONS.has(normalized as (typeof RICHTEXT_EXTENSIONS extends Set<infer T> ? T : never))
}
export function isRichTextMime(mime: string): boolean {
const m = mime.toLowerCase()
return RICHTEXT_MIME_HINTS.some((hint) => m.includes(hint))
}
export function isRichTextFile(file: { name: string; mime_type?: string }): boolean {
const ext = fileExtension(file.name)
if (ext && isRichTextExtension(ext)) return true
const mime = (file.mime_type ?? "").toLowerCase()
if (mime && isRichTextMime(mime)) return true
return false
}
export function isUltidocPath(path: string): boolean {
return path.toLowerCase().endsWith(`.${ULTIDOC_EXTENSION}`)
}
/** Sidecar path for a source document, e.g. /docs/report.docx → /docs/report.ultidoc.json */
export function sidecarPathForSource(sourcePath: string): string {
const normalized = sourcePath.replace(/\/+/g, "/")
const slash = normalized.lastIndexOf("/")
const dir = slash >= 0 ? normalized.slice(0, slash) : ""
const fileName = slash >= 0 ? normalized.slice(slash + 1) : normalized
const dot = fileName.lastIndexOf(".")
const base = dot > 0 ? fileName.slice(0, dot) : fileName
const sidecarName = `${base}.${ULTIDOC_EXTENSION}`
if (!dir) return `/${sidecarName}`
return `${dir}/${sidecarName}`.replace(/\/+/g, "/")
}
/** Source path from an ultidoc sidecar name (best-effort). */
export function guessSourcePathFromSidecar(sidecarPath: string, knownExtensions: string[]): string | null {
if (!isUltidocPath(sidecarPath)) return null
const normalized = sidecarPath.replace(/\/+/g, "/")
const slash = normalized.lastIndexOf("/")
const dir = slash >= 0 ? normalized.slice(0, slash) : ""
const fileName = slash >= 0 ? normalized.slice(slash + 1) : normalized
const base = fileName.slice(0, -(ULTIDOC_EXTENSION.length + 1))
for (const ext of knownExtensions) {
const candidate = `${base}.${ext}`
return dir ? `${dir}/${candidate}` : `/${candidate}`
}
return null
}

View File

@ -0,0 +1,108 @@
import mammoth from "mammoth"
export type TipTapJSON = Record<string, unknown>
function htmlToTipTapDoc(html: string): TipTapJSON {
const parser = typeof DOMParser !== "undefined" ? new DOMParser() : null
if (!parser) {
return {
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: html.replace(/<[^>]+>/g, " ") }] }],
}
}
const doc = parser.parseFromString(html, "text/html")
const blocks: TipTapJSON[] = []
const walk = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? ""
if (text.trim()) {
blocks.push({ type: "paragraph", content: [{ type: "text", text }] })
}
return
}
if (node.nodeType !== Node.ELEMENT_NODE) return
const el = node as HTMLElement
const tag = el.tagName.toLowerCase()
if (tag === "p" || tag === "div") {
const text = el.textContent ?? ""
blocks.push({
type: "paragraph",
content: text ? [{ type: "text", text }] : [],
})
return
}
if (/^h[1-6]$/.test(tag)) {
const level = Number(tag[1])
blocks.push({
type: "heading",
attrs: { level },
content: [{ type: "text", text: el.textContent ?? "" }],
})
return
}
if (tag === "ul" || tag === "ol") {
const listType = tag === "ul" ? "bulletList" : "orderedList"
const items = Array.from(el.querySelectorAll(":scope > li")).map((li) => ({
type: "listItem",
content: [{ type: "paragraph", content: [{ type: "text", text: li.textContent ?? "" }] }],
}))
blocks.push({ type: listType, content: items })
return
}
Array.from(el.childNodes).forEach(walk)
}
Array.from(doc.body.childNodes).forEach(walk)
if (blocks.length === 0) {
blocks.push({ type: "paragraph" })
}
return { type: "doc", content: blocks }
}
export async function importDocxToTipTap(buffer: ArrayBuffer): Promise<TipTapJSON> {
try {
const { parseDOCX } = await import("@docen/import-docx")
const content = await parseDOCX(buffer)
if (content && typeof content === "object") {
return content as TipTapJSON
}
} catch {
/* fallback mammoth */
}
const result = await mammoth.convertToHtml({ arrayBuffer: buffer })
return htmlToTipTapDoc(result.value)
}
export async function exportTipTapToDocx(content: TipTapJSON): Promise<Blob> {
try {
const { generateDOCX } = await import("@docen/export-docx")
const buf = await generateDOCX(content, { outputType: "blob" })
if (buf instanceof Blob) return buf
} catch {
/* fallback unavailable */
}
throw new Error("Export DOCX indisponible")
}
export async function importFileToTipTap(
fileName: string,
buffer: ArrayBuffer
): Promise<TipTapJSON> {
const ext = fileName.split(".").pop()?.toLowerCase() ?? ""
if (ext === "docx" || ext === "docm") {
return importDocxToTipTap(buffer)
}
const text = new TextDecoder().decode(buffer)
if (ext === "html" || ext === "htm") {
return htmlToTipTapDoc(text)
}
const lines = text.split(/\r?\n/)
return {
type: "doc",
content: lines.map((line) => ({
type: "paragraph",
content: line ? [{ type: "text", text: line }] : [],
})),
}
}

View File

@ -0,0 +1,14 @@
export type RichTextSessionResponse = {
roomId: string
canonicalPath: string
sourcePath?: string
wsUrl: string
token: string
mode: "edit" | "view"
importRequired: boolean
collaboration: boolean
documentUrl?: string
saveUrl?: string
}
export type RichTextSaveStatus = "saved" | "saving" | "error" | "idle"

View File

@ -13,6 +13,7 @@ import {
driveFiltersActive,
type DriveFiltersSnapshot,
} from "@/lib/stores/drive-filters-store"
import { filterHiddenDriveSidecars } from "@/lib/drive/drive-hidden-files"
export function useDriveFilteredItems(
items: DriveFileInfo[],
@ -33,29 +34,31 @@ export function useDriveFilteredItems(
const needsRecursiveCorpus = Boolean(options?.recursiveCorpus && filtersActive)
const corpusQuery = useDriveFilterCorpus(scopePath, needsRecursiveCorpus)
const visibleItems = useMemo(() => filterHiddenDriveSidecars(items), [items])
const filteredItems = useMemo(() => {
if (!filtersActive) {
return sortDriveItems(items, sort)
return sortDriveItems(visibleItems, sort)
}
const filterOptions = needsRecursiveCorpus
? corpusQuery.data?.files
? {
folderKeepPaths: buildDriveFolderPathsWithMatches(
corpusQuery.data.files,
filterHiddenDriveSidecars(corpusQuery.data.files),
filters,
scopePath
),
}
: corpusQuery.isError
? { matchCorpus: items, scopePath }
? { matchCorpus: visibleItems, scopePath }
: undefined
: { matchCorpus: items, scopePath }
: { matchCorpus: visibleItems, scopePath }
const filtered = applyDriveFilters(items, filters, filterOptions)
const filtered = applyDriveFilters(visibleItems, filters, filterOptions)
return sortDriveItems(filtered, sort)
}, [
items,
visibleItems,
filters,
sort,
filtersActive,

2
next-env.d.ts vendored
View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -21,9 +21,12 @@
"brand:authentik": "node scripts/emit-authentik-brand.mjs"
},
"dependencies": {
"@docen/export-docx": "^0.2.9",
"@docen/import-docx": "^0.2.9",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@formkit/auto-animate": "^0.9.0",
"@hocuspocus/provider": "^4.1.0",
"@hookform/resolvers": "^3.9.1",
"@iconify-json/cbi": "^1.2.36",
"@iconify-json/fluent": "^1.2.47",
@ -62,14 +65,24 @@
"@tanstack/react-query": "^5.100.13",
"@tanstack/react-query-persist-client": "^5.100.13",
"@tiptap/core": "^3.23.2",
"@tiptap/extension-collaboration": "^3.26.0",
"@tiptap/extension-collaboration-caret": "^3.26.0",
"@tiptap/extension-color": "^3.23.2",
"@tiptap/extension-highlight": "^3.26.0",
"@tiptap/extension-image": "^3.26.0",
"@tiptap/extension-link": "^3.23.2",
"@tiptap/extension-placeholder": "^3.26.0",
"@tiptap/extension-table": "^3.26.0",
"@tiptap/extension-table-cell": "^3.26.0",
"@tiptap/extension-table-header": "^3.26.0",
"@tiptap/extension-table-row": "^3.26.0",
"@tiptap/extension-text-align": "^3.23.2",
"@tiptap/extension-text-style": "^3.23.2",
"@tiptap/extension-underline": "^3.23.2",
"@tiptap/pm": "^3.23.2",
"@tiptap/react": "^3.23.2",
"@tiptap/starter-kit": "^3.23.2",
"@tiptap/y-tiptap": "^3.0.4",
"@vercel/analytics": "1.6.1",
"@xyflow/react": "^12.10.2",
"autoprefixer": "^10.4.20",
@ -85,6 +98,7 @@
"idb": "^8.0.3",
"input-otp": "1.4.2",
"lucide-react": "^0.564.0",
"mammoth": "^1.12.0",
"next": "16.2.7",
"next-themes": "^0.4.6",
"pdfjs-dist": "^6.0.227",
@ -97,6 +111,7 @@
"sonner": "^1.7.1",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"yjs": "^13.6.31",
"zod": "^3.24.1",
"zustand": "^5.0.13"
},

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,40 @@
/* TipTap collaboration carets (remote users) */
.collaboration-carets__caret {
border-left: 2px solid currentColor;
margin-left: -1px;
margin-right: -1px;
pointer-events: none;
position: relative;
word-break: normal;
}
.collaboration-carets__label {
border-radius: 4px 4px 4px 0;
color: #fff;
font-size: 11px;
font-weight: 600;
left: -1px;
line-height: 1.2;
padding: 2px 6px;
position: absolute;
top: -1.4em;
user-select: none;
white-space: nowrap;
}
.ultidrive-richtext-editor .ProseMirror-selectednode {
outline: 2px solid hsl(var(--primary) / 0.35);
}
.ultidrive-richtext-editor table {
border-collapse: collapse;
width: 100%;
}
.ultidrive-richtext-editor td,
.ultidrive-richtext-editor th {
border: 1px solid hsl(var(--border));
min-width: 80px;
padding: 6px 8px;
vertical-align: top;
}

File diff suppressed because one or more lines are too long