This commit is contained in:
parent
5304790ed5
commit
cdff12490a
@ -17,5 +17,7 @@ NEXT_PUBLIC_APP_URL=http://localhost
|
|||||||
# Secret serveur uniquement — doit matcher ULTID_OIDC_CLIENT_SECRET / blueprint
|
# Secret serveur uniquement — doit matcher ULTID_OIDC_CLIENT_SECRET / blueprint
|
||||||
OIDC_CLIENT_SECRET=changeme
|
OIDC_CLIENT_SECRET=changeme
|
||||||
|
|
||||||
# OnlyOffice Document Server (UltiDrive editor)
|
# OnlyOffice editor (UltiDrive — tableurs/présentations)
|
||||||
NEXT_PUBLIC_ONLYOFFICE_URL=http://localhost/office
|
NEXT_PUBLIC_ONLYOFFICE_URL=http://localhost/office
|
||||||
|
# Rich text editor (TipTap + Hocuspocus — docs texte)
|
||||||
|
NEXT_PUBLIC_HOCUSPOCUS_URL=ws://localhost/collab
|
||||||
|
|||||||
@ -2,11 +2,22 @@
|
|||||||
|
|
||||||
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 { shouldOpenInRichTextEditor } from "@/lib/drive/drive-preview"
|
||||||
|
|
||||||
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 filePath = decodeURIComponent(params.fileId as string)
|
||||||
const returnTo = searchParams.get("returnTo")
|
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} />
|
return <OfficeEditor filePath={filePath} returnTo={returnTo} />
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useParams, useSearchParams } from "next/navigation"
|
import { useParams, useSearchParams } from "next/navigation"
|
||||||
import { useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { PublicOfficeEditor } from "@/components/drive/public-office-editor"
|
import { PublicOfficeEditor } from "@/components/drive/public-office-editor"
|
||||||
import { 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() {
|
export default function PublicShareEditPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@ -14,11 +17,45 @@ export default function PublicShareEditPage() {
|
|||||||
const returnTo = searchParams.get("returnTo")
|
const returnTo = searchParams.get("returnTo")
|
||||||
const mode = searchParams.get("mode") === "view" ? "view" : "edit"
|
const mode = searchParams.get("mode") === "view" ? "view" : "edit"
|
||||||
const fileDisplayName = searchParams.get("name") ?? undefined
|
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>(() => {
|
const [password] = useState<string | undefined>(() => {
|
||||||
if (typeof window === "undefined") return undefined
|
if (typeof window === "undefined") return undefined
|
||||||
return sessionStorage.getItem(`public-share-pw:${token}`) ?? 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 (
|
return (
|
||||||
<PublicOfficeEditor
|
<PublicOfficeEditor
|
||||||
token={token}
|
token={token}
|
||||||
@ -27,6 +64,7 @@ export default function PublicShareEditPage() {
|
|||||||
returnTo={returnTo}
|
returnTo={returnTo}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
fileDisplayName={fileDisplayName}
|
fileDisplayName={fileDisplayName}
|
||||||
|
shareRoot={shareRoot}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
@import '../styles/onlyoffice-theme.css';
|
@import '../styles/onlyoffice-theme.css';
|
||||||
|
@import '../styles/richtext-editor.css';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { PluginsSection } from "@/components/admin/settings/sections/plugins-sec
|
|||||||
import { NextcloudSection } from "@/components/admin/settings/sections/nextcloud-section"
|
import { NextcloudSection } from "@/components/admin/settings/sections/nextcloud-section"
|
||||||
import { MailingSection } from "@/components/admin/settings/sections/mailing-section"
|
import { MailingSection } from "@/components/admin/settings/sections/mailing-section"
|
||||||
import { OnlyofficeSection } from "@/components/admin/settings/sections/onlyoffice-section"
|
import { OnlyofficeSection } from "@/components/admin/settings/sections/onlyoffice-section"
|
||||||
|
import { RichtextSection } from "@/components/admin/settings/sections/richtext-section"
|
||||||
import { AuditSection } from "@/components/admin/settings/sections/audit-section"
|
import { AuditSection } from "@/components/admin/settings/sections/audit-section"
|
||||||
|
|
||||||
const SECTIONS: Record<AdminSettingsSectionId, React.ComponentType> = {
|
const SECTIONS: Record<AdminSettingsSectionId, React.ComponentType> = {
|
||||||
@ -36,6 +37,7 @@ const SECTIONS: Record<AdminSettingsSectionId, React.ComponentType> = {
|
|||||||
nextcloud: NextcloudSection,
|
nextcloud: NextcloudSection,
|
||||||
mailing: MailingSection,
|
mailing: MailingSection,
|
||||||
onlyoffice: OnlyofficeSection,
|
onlyoffice: OnlyofficeSection,
|
||||||
|
richtext: RichtextSection,
|
||||||
audit: AuditSection,
|
audit: AuditSection,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
89
components/admin/settings/sections/richtext-section.tsx
Normal file
89
components/admin/settings/sections/richtext-section.tsx
Normal 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'é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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -24,6 +24,7 @@ export function OfficeEditorChrome({
|
|||||||
backHref,
|
backHref,
|
||||||
backLabel,
|
backLabel,
|
||||||
title,
|
title,
|
||||||
|
showBack = true,
|
||||||
onRename,
|
onRename,
|
||||||
renameDisabled = false,
|
renameDisabled = false,
|
||||||
shares = [],
|
shares = [],
|
||||||
@ -32,9 +33,10 @@ export function OfficeEditorChrome({
|
|||||||
showAccount = false,
|
showAccount = false,
|
||||||
trailing,
|
trailing,
|
||||||
}: {
|
}: {
|
||||||
backHref: string
|
backHref?: string
|
||||||
backLabel: string
|
backLabel?: string
|
||||||
title: string
|
title: string
|
||||||
|
showBack?: boolean
|
||||||
onRename?: (next: string) => Promise<void>
|
onRename?: (next: string) => Promise<void>
|
||||||
renameDisabled?: boolean
|
renameDisabled?: boolean
|
||||||
shares?: DriveShare[]
|
shares?: DriveShare[]
|
||||||
@ -48,12 +50,14 @@ export function OfficeEditorChrome({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3 ultidrive-editor-chrome">
|
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3 ultidrive-editor-chrome">
|
||||||
|
{showBack && backHref ? (
|
||||||
<Button variant="ghost" size="sm" asChild className="shrink-0">
|
<Button variant="ghost" size="sm" asChild className="shrink-0">
|
||||||
<Link href={backHref}>
|
<Link href={backHref}>
|
||||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||||
{backLabel}
|
{backLabel ?? "Retour"}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{onRename ? (
|
{onRename ? (
|
||||||
|
|||||||
@ -7,7 +7,9 @@ import { ArrowLeft } from "lucide-react"
|
|||||||
import { OnlyOfficeMount } from "@/components/drive/onlyoffice-mount"
|
import { OnlyOfficeMount } from "@/components/drive/onlyoffice-mount"
|
||||||
import { OfficeEditorChrome } from "@/components/drive/office-editor-chrome"
|
import { OfficeEditorChrome } from "@/components/drive/office-editor-chrome"
|
||||||
import { displayFileBaseName } from "@/lib/drive/display-file-name"
|
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"
|
import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title"
|
||||||
|
|
||||||
function fileNameFromPath(filePath: string, fallback?: string): string {
|
function fileNameFromPath(filePath: string, fallback?: string): string {
|
||||||
@ -22,6 +24,7 @@ export function PublicOfficeEditor({
|
|||||||
returnTo,
|
returnTo,
|
||||||
mode = "edit",
|
mode = "edit",
|
||||||
fileDisplayName,
|
fileDisplayName,
|
||||||
|
shareRoot,
|
||||||
}: {
|
}: {
|
||||||
token: string
|
token: string
|
||||||
filePath: string
|
filePath: string
|
||||||
@ -29,13 +32,10 @@ export function PublicOfficeEditor({
|
|||||||
returnTo?: string | null
|
returnTo?: string | null
|
||||||
mode?: "edit" | "view"
|
mode?: "edit" | "view"
|
||||||
fileDisplayName?: string
|
fileDisplayName?: string
|
||||||
|
shareRoot?: PublicShareRootType | null
|
||||||
}) {
|
}) {
|
||||||
const instanceSeq = useRef(0)
|
const instanceSeq = useRef(0)
|
||||||
const guestId = useRef(
|
const guest = useMemo(() => getGuestEditorIdentity(token), [token])
|
||||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `guest-${Date.now()}`
|
|
||||||
)
|
|
||||||
const [config, setConfig] = useState<Record<string, unknown> | null>(null)
|
const [config, setConfig] = useState<Record<string, unknown> | null>(null)
|
||||||
const [serverUrl, setServerUrl] = useState("")
|
const [serverUrl, setServerUrl] = useState("")
|
||||||
const [editorId, setEditorId] = useState<string | null>(null)
|
const [editorId, setEditorId] = useState<string | null>(null)
|
||||||
@ -50,6 +50,7 @@ export function PublicOfficeEditor({
|
|||||||
() => resolvePublicShareEditReturnTo(token, returnTo, filePath),
|
() => resolvePublicShareEditReturnTo(token, returnTo, filePath),
|
||||||
[token, returnTo, filePath]
|
[token, returnTo, filePath]
|
||||||
)
|
)
|
||||||
|
const showBack = shouldShowPublicShareEditorBack(shareRoot, returnTo, filePath)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@ -70,7 +71,8 @@ export function PublicOfficeEditor({
|
|||||||
path: filePath,
|
path: filePath,
|
||||||
mode,
|
mode,
|
||||||
password: password ?? "",
|
password: password ?? "",
|
||||||
guest_id: guestId.current,
|
guest_id: guest.guestId,
|
||||||
|
guest_name: guest.guestName,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -111,12 +113,14 @@ export function PublicOfficeEditor({
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-dvh flex-col items-center justify-center gap-4">
|
<div className="flex h-dvh flex-col items-center justify-center gap-4">
|
||||||
<p className="text-destructive">{error}</p>
|
<p className="text-destructive">{error}</p>
|
||||||
|
{showBack ? (
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href={backHref}>
|
<Link href={backHref}>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Retour
|
Retour
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -130,6 +134,7 @@ export function PublicOfficeEditor({
|
|||||||
<OfficeEditorChrome
|
<OfficeEditorChrome
|
||||||
backHref={backHref}
|
backHref={backHref}
|
||||||
backLabel="Partage"
|
backLabel="Partage"
|
||||||
|
showBack={showBack}
|
||||||
title={title}
|
title={title}
|
||||||
trailing={
|
trailing={
|
||||||
resolvedMode === "view" ? (
|
resolvedMode === "view" ? (
|
||||||
|
|||||||
202
components/drive/public-richtext-editor.tsx
Normal file
202
components/drive/public-richtext-editor.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -21,14 +21,15 @@ import type { DriveFileInfo } from "@/lib/api/types"
|
|||||||
import { drivePreviewKind, isSvgFile } from "@/lib/drive/drive-preview"
|
import { drivePreviewKind, isSvgFile } from "@/lib/drive/drive-preview"
|
||||||
import { SvgPreviewViewer } from "@/components/drive/svg-preview-viewer"
|
import { SvgPreviewViewer } from "@/components/drive/svg-preview-viewer"
|
||||||
import { PUBLIC_SHARE_INSET_X } from "@/lib/drive/drive-chrome-classes"
|
import { PUBLIC_SHARE_INSET_X } from "@/lib/drive/drive-chrome-classes"
|
||||||
import { shouldOpenInOnlyOffice } from "@/lib/drive/drive-preview"
|
import { shouldOpenInOnlyOffice, shouldOpenInRichTextEditor } from "@/lib/drive/drive-preview"
|
||||||
import {
|
import {
|
||||||
sharePermCanEdit,
|
sharePermCanEdit,
|
||||||
} from "@/lib/drive/drive-share-permissions"
|
} 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 { suitePublicAsset } from "@/lib/suite/suite-public-asset"
|
||||||
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
|
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { filterHiddenDriveSidecars } from "@/lib/drive/drive-hidden-files"
|
||||||
|
|
||||||
const PdfPreviewViewer = dynamic(
|
const PdfPreviewViewer = dynamic(
|
||||||
() => import("@/components/drive/pdf-preview-viewer").then((m) => m.PdfPreviewViewer),
|
() => import("@/components/drive/pdf-preview-viewer").then((m) => m.PdfPreviewViewer),
|
||||||
@ -214,28 +215,37 @@ export function PublicShareViewPanel({
|
|||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const file = data.item_type === "file" ? data.file : null
|
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 rootShareName = usePublicShareRootName(token, path, data.name)
|
||||||
const sharedByLabel = publicShareOwnerLabel(data)
|
const sharedByLabel = publicShareOwnerLabel(data)
|
||||||
const permissions = data.permissions ?? 1
|
const permissions = data.permissions ?? 1
|
||||||
const canEdit = sharePermCanEdit(permissions)
|
const canEdit = sharePermCanEdit(permissions)
|
||||||
|
|
||||||
useEffect(() => {
|
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 =
|
const returnTo =
|
||||||
typeof window !== "undefined"
|
typeof window !== "undefined"
|
||||||
? window.location.pathname + window.location.search
|
? window.location.pathname + window.location.search
|
||||||
: undefined
|
: undefined
|
||||||
|
const editor = shouldOpenInRichTextEditor(file) ? "richtext" : "office"
|
||||||
router.replace(
|
router.replace(
|
||||||
buildPublicShareEditHref(
|
buildPublicShareEditHref(
|
||||||
token,
|
token,
|
||||||
file.path,
|
file.path,
|
||||||
returnTo,
|
returnTo,
|
||||||
canEdit ? "edit" : "view",
|
canEdit ? "edit" : "view",
|
||||||
file.name
|
file.name,
|
||||||
|
editor,
|
||||||
|
data.item_type
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}, [canEdit, file, router, token])
|
}, [canEdit, data.item_type, file, router, token])
|
||||||
|
|
||||||
const downloadCurrent = () => {
|
const downloadCurrent = () => {
|
||||||
if (!file) return
|
if (!file) return
|
||||||
@ -249,7 +259,7 @@ export function PublicShareViewPanel({
|
|||||||
anchor.remove()
|
anchor.remove()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file && shouldOpenInOnlyOffice(file)) {
|
if (file && (shouldOpenInRichTextEditor(file) || shouldOpenInOnlyOffice(file))) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[40vh] items-center justify-center">
|
<div className="flex min-h-[40vh] 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" />
|
||||||
|
|||||||
235
components/drive/richtext-document.tsx
Normal file
235
components/drive/richtext-document.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
176
components/drive/richtext-editor.tsx
Normal file
176
components/drive/richtext-editor.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
components/drive/richtext-toolbar.tsx
Normal file
97
components/drive/richtext-toolbar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
e2e/richtext-editor.spec.ts
Normal file
41
e2e/richtext-editor.spec.ts
Normal 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -128,6 +128,12 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
|
|||||||
nextcloud: { ...policy.nextcloud },
|
nextcloud: { ...policy.nextcloud },
|
||||||
mailing: { ...policy.mailing },
|
mailing: { ...policy.mailing },
|
||||||
onlyoffice: { ...policy.onlyoffice },
|
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 ?? [],
|
plugins: policy.plugins ?? [],
|
||||||
integrations: mergeIntegrations(policy.integrations as IntegrationEntry[]),
|
integrations: mergeIntegrations(policy.integrations as IntegrationEntry[]),
|
||||||
}
|
}
|
||||||
@ -180,6 +186,7 @@ export function storeToApiOrgPolicy(state: OrgSettingsState): ApiOrgPolicy {
|
|||||||
nextcloud: { ...state.nextcloud },
|
nextcloud: { ...state.nextcloud },
|
||||||
mailing: { ...state.mailing },
|
mailing: { ...state.mailing },
|
||||||
onlyoffice: { ...state.onlyoffice },
|
onlyoffice: { ...state.onlyoffice },
|
||||||
|
richtext: { ...state.richtext },
|
||||||
plugins: state.plugins.map(({ id, name, description, enabled, version }) => ({
|
plugins: state.plugins.map(({ id, name, description, enabled, version }) => ({
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type {
|
|||||||
MailingSettings,
|
MailingSettings,
|
||||||
NextcloudSettings,
|
NextcloudSettings,
|
||||||
OnlyOfficeSettings,
|
OnlyOfficeSettings,
|
||||||
|
RichTextSettings,
|
||||||
OrgLLMSettings,
|
OrgLLMSettings,
|
||||||
OrgSearchSettings,
|
OrgSearchSettings,
|
||||||
OrgStorageQuotas,
|
OrgStorageQuotas,
|
||||||
@ -115,6 +116,13 @@ const DEFAULT_ONLYOFFICE: OnlyOfficeSettings = {
|
|||||||
jwt_header: "Authorization",
|
jwt_header: "Authorization",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_RICHTEXT: RichTextSettings = {
|
||||||
|
enabled: true,
|
||||||
|
storage_mode: "sidecar",
|
||||||
|
export_mirror_format: "",
|
||||||
|
hocuspocus_url: "",
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_PLUGINS: PluginEntry[] = [
|
const DEFAULT_PLUGINS: PluginEntry[] = [
|
||||||
{
|
{
|
||||||
id: "mail-automation",
|
id: "mail-automation",
|
||||||
@ -144,6 +152,13 @@ const DEFAULT_PLUGINS: PluginEntry[] = [
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
version: "1.0.0",
|
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[] = [
|
const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
|
||||||
@ -193,6 +208,7 @@ type OrgSettingsActions = {
|
|||||||
setNextcloud: (patch: Partial<NextcloudSettings>) => void
|
setNextcloud: (patch: Partial<NextcloudSettings>) => void
|
||||||
setMailing: (patch: Partial<MailingSettings>) => void
|
setMailing: (patch: Partial<MailingSettings>) => void
|
||||||
setOnlyoffice: (patch: Partial<OnlyOfficeSettings>) => void
|
setOnlyoffice: (patch: Partial<OnlyOfficeSettings>) => void
|
||||||
|
setRichtext: (patch: Partial<RichTextSettings>) => void
|
||||||
setAdministrators: (admins: Administrator[]) => void
|
setAdministrators: (admins: Administrator[]) => void
|
||||||
addAdministrator: (admin: Administrator) => void
|
addAdministrator: (admin: Administrator) => void
|
||||||
removeAdministrator: (id: string) => void
|
removeAdministrator: (id: string) => void
|
||||||
@ -214,6 +230,7 @@ type OrgSettingsActions = {
|
|||||||
nextcloud: NextcloudSettings
|
nextcloud: NextcloudSettings
|
||||||
mailing: MailingSettings
|
mailing: MailingSettings
|
||||||
onlyoffice: OnlyOfficeSettings
|
onlyoffice: OnlyOfficeSettings
|
||||||
|
richtext: RichTextSettings
|
||||||
plugins: PluginEntry[]
|
plugins: PluginEntry[]
|
||||||
integrations: IntegrationEntry[]
|
integrations: IntegrationEntry[]
|
||||||
}>, meta?: OrgSettingsMeta) => void
|
}>, meta?: OrgSettingsMeta) => void
|
||||||
@ -233,6 +250,7 @@ export const useOrgSettingsStore = create<
|
|||||||
nextcloud: NextcloudSettings
|
nextcloud: NextcloudSettings
|
||||||
mailing: MailingSettings
|
mailing: MailingSettings
|
||||||
onlyoffice: OnlyOfficeSettings
|
onlyoffice: OnlyOfficeSettings
|
||||||
|
richtext: RichTextSettings
|
||||||
plugins: PluginEntry[]
|
plugins: PluginEntry[]
|
||||||
integrations: IntegrationEntry[]
|
integrations: IntegrationEntry[]
|
||||||
meta: OrgSettingsMeta | null
|
meta: OrgSettingsMeta | null
|
||||||
@ -251,6 +269,7 @@ export const useOrgSettingsStore = create<
|
|||||||
nextcloud: DEFAULT_NEXTCLOUD,
|
nextcloud: DEFAULT_NEXTCLOUD,
|
||||||
mailing: DEFAULT_MAILING,
|
mailing: DEFAULT_MAILING,
|
||||||
onlyoffice: DEFAULT_ONLYOFFICE,
|
onlyoffice: DEFAULT_ONLYOFFICE,
|
||||||
|
richtext: DEFAULT_RICHTEXT,
|
||||||
plugins: DEFAULT_PLUGINS,
|
plugins: DEFAULT_PLUGINS,
|
||||||
integrations: DEFAULT_INTEGRATIONS,
|
integrations: DEFAULT_INTEGRATIONS,
|
||||||
meta: null,
|
meta: null,
|
||||||
@ -278,6 +297,8 @@ export const useOrgSettingsStore = create<
|
|||||||
set((s) => ({ mailing: { ...s.mailing, ...patch } })),
|
set((s) => ({ mailing: { ...s.mailing, ...patch } })),
|
||||||
setOnlyoffice: (patch) =>
|
setOnlyoffice: (patch) =>
|
||||||
set((s) => ({ onlyoffice: { ...s.onlyoffice, ...patch } })),
|
set((s) => ({ onlyoffice: { ...s.onlyoffice, ...patch } })),
|
||||||
|
setRichtext: (patch) =>
|
||||||
|
set((s) => ({ richtext: { ...s.richtext, ...patch } })),
|
||||||
setAdministrators: (administrators) => set({ administrators }),
|
setAdministrators: (administrators) => set({ administrators }),
|
||||||
addAdministrator: (admin) =>
|
addAdministrator: (admin) =>
|
||||||
set((s) => ({ administrators: [...s.administrators, admin] })),
|
set((s) => ({ administrators: [...s.administrators, admin] })),
|
||||||
|
|||||||
@ -165,6 +165,13 @@ export type OnlyOfficeSettings = {
|
|||||||
jwt_header: string
|
jwt_header: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RichTextSettings = {
|
||||||
|
enabled: boolean
|
||||||
|
storage_mode: "sidecar" | "overwrite"
|
||||||
|
export_mirror_format: "" | "docx"
|
||||||
|
hocuspocus_url: string
|
||||||
|
}
|
||||||
|
|
||||||
export type PluginEntry = {
|
export type PluginEntry = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@ -195,6 +202,7 @@ export type OrgSettingsState = {
|
|||||||
nextcloud: NextcloudSettings
|
nextcloud: NextcloudSettings
|
||||||
mailing: MailingSettings
|
mailing: MailingSettings
|
||||||
onlyoffice: OnlyOfficeSettings
|
onlyoffice: OnlyOfficeSettings
|
||||||
|
richtext: RichTextSettings
|
||||||
plugins: PluginEntry[]
|
plugins: PluginEntry[]
|
||||||
integrations: IntegrationEntry[]
|
integrations: IntegrationEntry[]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
Cloud,
|
Cloud,
|
||||||
FileCog,
|
FileCog,
|
||||||
|
FileText,
|
||||||
Gauge,
|
Gauge,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Link2,
|
Link2,
|
||||||
@ -32,6 +33,7 @@ export type AdminSettingsSectionId =
|
|||||||
| "nextcloud"
|
| "nextcloud"
|
||||||
| "mailing"
|
| "mailing"
|
||||||
| "onlyoffice"
|
| "onlyoffice"
|
||||||
|
| "richtext"
|
||||||
| "audit"
|
| "audit"
|
||||||
|
|
||||||
export type AdminSettingsNavItem = {
|
export type AdminSettingsNavItem = {
|
||||||
@ -141,6 +143,13 @@ export const ADMIN_SETTINGS_NAV: AdminSettingsNavItem[] = [
|
|||||||
href: "/admin/settings/onlyoffice",
|
href: "/admin/settings/onlyoffice",
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "richtext",
|
||||||
|
label: "Éditeur rich text",
|
||||||
|
description: "TipTap pour documents texte",
|
||||||
|
href: "/admin/settings/richtext",
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "audit",
|
id: "audit",
|
||||||
label: "Journal d'audit",
|
label: "Journal d'audit",
|
||||||
|
|||||||
@ -152,6 +152,13 @@ export type ApiOrgOnlyoffice = {
|
|||||||
jwt_header: string
|
jwt_header: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ApiOrgRichText = {
|
||||||
|
enabled: boolean
|
||||||
|
storage_mode: "sidecar" | "overwrite"
|
||||||
|
export_mirror_format: "" | "docx"
|
||||||
|
hocuspocus_url: string
|
||||||
|
}
|
||||||
|
|
||||||
export type ApiOrgPlugin = {
|
export type ApiOrgPlugin = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@ -181,6 +188,7 @@ export type ApiOrgPolicy = {
|
|||||||
nextcloud: ApiOrgNextcloud
|
nextcloud: ApiOrgNextcloud
|
||||||
mailing: ApiOrgMailing
|
mailing: ApiOrgMailing
|
||||||
onlyoffice: ApiOrgOnlyoffice
|
onlyoffice: ApiOrgOnlyoffice
|
||||||
|
richtext?: ApiOrgRichText
|
||||||
plugins: ApiOrgPlugin[]
|
plugins: ApiOrgPlugin[]
|
||||||
integrations: ApiOrgIntegration[]
|
integrations: ApiOrgIntegration[]
|
||||||
}
|
}
|
||||||
|
|||||||
49
lib/drive/drive-hidden-files.ts
Normal file
49
lib/drive/drive-hidden-files.ts
Normal 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))
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import {
|
|||||||
drivePreviewKind,
|
drivePreviewKind,
|
||||||
isPreviewNavigable,
|
isPreviewNavigable,
|
||||||
shouldOpenInOnlyOffice,
|
shouldOpenInOnlyOffice,
|
||||||
|
shouldOpenInRichTextEditor,
|
||||||
toPreviewTarget,
|
toPreviewTarget,
|
||||||
} 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"
|
||||||
@ -44,6 +45,10 @@ export function openDriveItem(file: DriveFileInfo, options: OpenDriveItemOptions
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (drivePreviewKind(file)) {
|
if (drivePreviewKind(file)) {
|
||||||
|
const ext = file.name.split(".").pop()?.toLowerCase() ?? ""
|
||||||
|
const richTextPreview =
|
||||||
|
ext === "md" || ext === "markdown" || ext === "txt" || ext === "html" || ext === "htm"
|
||||||
|
if (!richTextPreview) {
|
||||||
const navigable = contextItems
|
const navigable = contextItems
|
||||||
.filter((item) => isPreviewNavigable(item))
|
.filter((item) => isPreviewNavigable(item))
|
||||||
.map((item) => toPreviewTarget(item))
|
.map((item) => toPreviewTarget(item))
|
||||||
@ -51,6 +56,16 @@ export function openDriveItem(file: DriveFileInfo, options: OpenDriveItemOptions
|
|||||||
openPreview(navigable, index >= 0 ? index : 0, { allowShare, isTrash })
|
openPreview(navigable, index >= 0 ? index : 0, { allowShare, isTrash })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldOpenInRichTextEditor(file)) {
|
||||||
|
const returnTo =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window.location.pathname + window.location.search
|
||||||
|
: undefined
|
||||||
|
router.push(buildDriveEditHref(file.path, returnTo, "richtext"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldOpenInOnlyOffice(file)) {
|
if (shouldOpenInOnlyOffice(file)) {
|
||||||
const returnTo =
|
const returnTo =
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { DriveFileInfo } from "@/lib/api/types"
|
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"
|
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)
|
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: {
|
export function shouldOpenInOnlyOffice(file: {
|
||||||
name: string
|
name: string
|
||||||
mime_type?: string
|
mime_type?: string
|
||||||
type?: string
|
type?: string
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (file.type === "directory") return false
|
if (file.type === "directory") return false
|
||||||
|
if (shouldOpenInRichTextEditor(file)) return false
|
||||||
if (drivePreviewKind(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: {
|
export function drivePreviewKind(file: {
|
||||||
|
|||||||
@ -146,10 +146,19 @@ function isSafeDriveReturnPath(path: string): boolean {
|
|||||||
return true
|
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)}`
|
const base = `/drive/edit/${encodeURIComponent(filePath)}`
|
||||||
if (!returnTo || !isSafeDriveReturnPath(returnTo)) return base
|
const qs = params.toString()
|
||||||
return `${base}?returnTo=${encodeURIComponent(returnTo)}`
|
return qs ? `${base}?${qs}` : base
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve back link from editor: prefer explicit returnTo, else parent folder. */
|
/** Resolve back link from editor: prefer explicit returnTo, else parent folder. */
|
||||||
|
|||||||
153
lib/drive/guest-editor-identity.ts
Normal file
153
lib/drive/guest-editor-identity.ts
Normal 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
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import {
|
|||||||
drivePreviewKind,
|
drivePreviewKind,
|
||||||
isPreviewNavigable,
|
isPreviewNavigable,
|
||||||
shouldOpenInOnlyOffice,
|
shouldOpenInOnlyOffice,
|
||||||
|
shouldOpenInRichTextEditor,
|
||||||
toPreviewTarget,
|
toPreviewTarget,
|
||||||
} from "@/lib/drive/drive-preview"
|
} from "@/lib/drive/drive-preview"
|
||||||
import { fetchPublicShareBlob, publicShareDownloadApiPath, publicShareHref } from "@/lib/api/public-share"
|
import { fetchPublicShareBlob, publicShareDownloadApiPath, publicShareHref } from "@/lib/api/public-share"
|
||||||
@ -35,6 +36,10 @@ export function openPublicShareItem(file: DriveFileInfo, options: OpenPublicShar
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (drivePreviewKind(file)) {
|
if (drivePreviewKind(file)) {
|
||||||
|
const ext = file.name.split(".").pop()?.toLowerCase() ?? ""
|
||||||
|
const richTextPreview =
|
||||||
|
ext === "md" || ext === "markdown" || ext === "txt" || ext === "html" || ext === "htm"
|
||||||
|
if (!richTextPreview) {
|
||||||
const navigable = contextItems
|
const navigable = contextItems
|
||||||
.filter((item) => isPreviewNavigable(item))
|
.filter((item) => isPreviewNavigable(item))
|
||||||
.map((item) => toPreviewTarget(item))
|
.map((item) => toPreviewTarget(item))
|
||||||
@ -46,6 +51,19 @@ export function openPublicShareItem(file: DriveFileInfo, options: OpenPublicShar
|
|||||||
})
|
})
|
||||||
return
|
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
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldOpenInOnlyOffice(file)) {
|
if (shouldOpenInOnlyOffice(file)) {
|
||||||
const returnTo =
|
const returnTo =
|
||||||
@ -53,7 +71,7 @@ export function openPublicShareItem(file: DriveFileInfo, options: OpenPublicShar
|
|||||||
? window.location.pathname + window.location.search
|
? window.location.pathname + window.location.search
|
||||||
: undefined
|
: undefined
|
||||||
const mode = canEdit ? "edit" : "view"
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
export function buildPublicShareEditHref(
|
||||||
token: string,
|
token: string,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
returnTo?: string,
|
returnTo?: string,
|
||||||
mode: "edit" | "view" = "edit",
|
mode: "edit" | "view" = "edit",
|
||||||
displayName?: string
|
displayName?: string,
|
||||||
|
editor: "office" | "richtext" = "office",
|
||||||
|
shareRoot?: PublicShareRootType
|
||||||
): string {
|
): string {
|
||||||
const trimmed = filePath.replace(/^\/+|\/+$/g, "")
|
const trimmed = filePath.replace(/^\/+|\/+$/g, "")
|
||||||
const base = `/drive/s/${encodeURIComponent(token)}/edit/${trimmed.split("/").map(encodeURIComponent).join("/")}`
|
const base = `/drive/s/${encodeURIComponent(token)}/edit/${trimmed.split("/").map(encodeURIComponent).join("/")}`
|
||||||
@ -14,9 +46,15 @@ export function buildPublicShareEditHref(
|
|||||||
if (mode === "view") {
|
if (mode === "view") {
|
||||||
params.set("mode", "view")
|
params.set("mode", "view")
|
||||||
}
|
}
|
||||||
|
if (editor === "richtext") {
|
||||||
|
params.set("editor", "richtext")
|
||||||
|
}
|
||||||
if (displayName?.trim()) {
|
if (displayName?.trim()) {
|
||||||
params.set("name", displayName.trim())
|
params.set("name", displayName.trim())
|
||||||
}
|
}
|
||||||
|
if (shareRoot) {
|
||||||
|
params.set("shareRoot", shareRoot)
|
||||||
|
}
|
||||||
const qs = params.toString()
|
const qs = params.toString()
|
||||||
return qs ? `${base}?${qs}` : base
|
return qs ? `${base}?${qs}` : base
|
||||||
}
|
}
|
||||||
|
|||||||
71
lib/drive/richtext-extensions.ts
Normal file
71
lib/drive/richtext-extensions.ts
Normal 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"
|
||||||
105
lib/drive/richtext-formats.ts
Normal file
105
lib/drive/richtext-formats.ts
Normal 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
|
||||||
|
}
|
||||||
108
lib/drive/richtext-import.ts
Normal file
108
lib/drive/richtext-import.ts
Normal 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 }] : [],
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
14
lib/drive/richtext-types.ts
Normal file
14
lib/drive/richtext-types.ts
Normal 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"
|
||||||
@ -13,6 +13,7 @@ import {
|
|||||||
driveFiltersActive,
|
driveFiltersActive,
|
||||||
type DriveFiltersSnapshot,
|
type DriveFiltersSnapshot,
|
||||||
} from "@/lib/stores/drive-filters-store"
|
} from "@/lib/stores/drive-filters-store"
|
||||||
|
import { filterHiddenDriveSidecars } from "@/lib/drive/drive-hidden-files"
|
||||||
|
|
||||||
export function useDriveFilteredItems(
|
export function useDriveFilteredItems(
|
||||||
items: DriveFileInfo[],
|
items: DriveFileInfo[],
|
||||||
@ -33,29 +34,31 @@ export function useDriveFilteredItems(
|
|||||||
const needsRecursiveCorpus = Boolean(options?.recursiveCorpus && filtersActive)
|
const needsRecursiveCorpus = Boolean(options?.recursiveCorpus && filtersActive)
|
||||||
const corpusQuery = useDriveFilterCorpus(scopePath, needsRecursiveCorpus)
|
const corpusQuery = useDriveFilterCorpus(scopePath, needsRecursiveCorpus)
|
||||||
|
|
||||||
|
const visibleItems = useMemo(() => filterHiddenDriveSidecars(items), [items])
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
if (!filtersActive) {
|
if (!filtersActive) {
|
||||||
return sortDriveItems(items, sort)
|
return sortDriveItems(visibleItems, sort)
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterOptions = needsRecursiveCorpus
|
const filterOptions = needsRecursiveCorpus
|
||||||
? corpusQuery.data?.files
|
? corpusQuery.data?.files
|
||||||
? {
|
? {
|
||||||
folderKeepPaths: buildDriveFolderPathsWithMatches(
|
folderKeepPaths: buildDriveFolderPathsWithMatches(
|
||||||
corpusQuery.data.files,
|
filterHiddenDriveSidecars(corpusQuery.data.files),
|
||||||
filters,
|
filters,
|
||||||
scopePath
|
scopePath
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: corpusQuery.isError
|
: corpusQuery.isError
|
||||||
? { matchCorpus: items, scopePath }
|
? { matchCorpus: visibleItems, scopePath }
|
||||||
: undefined
|
: undefined
|
||||||
: { matchCorpus: items, scopePath }
|
: { matchCorpus: visibleItems, scopePath }
|
||||||
|
|
||||||
const filtered = applyDriveFilters(items, filters, filterOptions)
|
const filtered = applyDriveFilters(visibleItems, filters, filterOptions)
|
||||||
return sortDriveItems(filtered, sort)
|
return sortDriveItems(filtered, sort)
|
||||||
}, [
|
}, [
|
||||||
items,
|
visibleItems,
|
||||||
filters,
|
filters,
|
||||||
sort,
|
sort,
|
||||||
filtersActive,
|
filtersActive,
|
||||||
|
|||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
15
package.json
15
package.json
@ -21,9 +21,12 @@
|
|||||||
"brand:authentik": "node scripts/emit-authentik-brand.mjs"
|
"brand:authentik": "node scripts/emit-authentik-brand.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@docen/export-docx": "^0.2.9",
|
||||||
|
"@docen/import-docx": "^0.2.9",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@formkit/auto-animate": "^0.9.0",
|
"@formkit/auto-animate": "^0.9.0",
|
||||||
|
"@hocuspocus/provider": "^4.1.0",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@iconify-json/cbi": "^1.2.36",
|
"@iconify-json/cbi": "^1.2.36",
|
||||||
"@iconify-json/fluent": "^1.2.47",
|
"@iconify-json/fluent": "^1.2.47",
|
||||||
@ -62,14 +65,24 @@
|
|||||||
"@tanstack/react-query": "^5.100.13",
|
"@tanstack/react-query": "^5.100.13",
|
||||||
"@tanstack/react-query-persist-client": "^5.100.13",
|
"@tanstack/react-query-persist-client": "^5.100.13",
|
||||||
"@tiptap/core": "^3.23.2",
|
"@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-color": "^3.23.2",
|
||||||
|
"@tiptap/extension-highlight": "^3.26.0",
|
||||||
|
"@tiptap/extension-image": "^3.26.0",
|
||||||
"@tiptap/extension-link": "^3.23.2",
|
"@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-align": "^3.23.2",
|
||||||
"@tiptap/extension-text-style": "^3.23.2",
|
"@tiptap/extension-text-style": "^3.23.2",
|
||||||
"@tiptap/extension-underline": "^3.23.2",
|
"@tiptap/extension-underline": "^3.23.2",
|
||||||
"@tiptap/pm": "^3.23.2",
|
"@tiptap/pm": "^3.23.2",
|
||||||
"@tiptap/react": "^3.23.2",
|
"@tiptap/react": "^3.23.2",
|
||||||
"@tiptap/starter-kit": "^3.23.2",
|
"@tiptap/starter-kit": "^3.23.2",
|
||||||
|
"@tiptap/y-tiptap": "^3.0.4",
|
||||||
"@vercel/analytics": "1.6.1",
|
"@vercel/analytics": "1.6.1",
|
||||||
"@xyflow/react": "^12.10.2",
|
"@xyflow/react": "^12.10.2",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
@ -85,6 +98,7 @@
|
|||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"lucide-react": "^0.564.0",
|
"lucide-react": "^0.564.0",
|
||||||
|
"mammoth": "^1.12.0",
|
||||||
"next": "16.2.7",
|
"next": "16.2.7",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pdfjs-dist": "^6.0.227",
|
"pdfjs-dist": "^6.0.227",
|
||||||
@ -97,6 +111,7 @@
|
|||||||
"sonner": "^1.7.1",
|
"sonner": "^1.7.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
"yjs": "^13.6.31",
|
||||||
"zod": "^3.24.1",
|
"zod": "^3.24.1",
|
||||||
"zustand": "^5.0.13"
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
|
|||||||
691
pnpm-lock.yaml
691
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
40
styles/richtext-editor.css
Normal file
40
styles/richtext-editor.css
Normal 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
Loading…
Reference in New Issue
Block a user