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