This commit is contained in:
parent
5b1cc5e83c
commit
79bb6193fc
@ -11,11 +11,14 @@ export function OfficeEditorInlineTitle({
|
|||||||
onRename,
|
onRename,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className,
|
className,
|
||||||
|
renameSignal,
|
||||||
}: {
|
}: {
|
||||||
value: string
|
value: string
|
||||||
onRename: (next: string) => Promise<void>
|
onRename: (next: string) => Promise<void>
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
/** Increment to start inline rename from an external action (e.g. menu Fichier). */
|
||||||
|
renameSignal?: number
|
||||||
}) {
|
}) {
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [draft, setDraft] = useState(value)
|
const [draft, setDraft] = useState(value)
|
||||||
@ -27,6 +30,11 @@ export function OfficeEditorInlineTitle({
|
|||||||
if (!editing) setDraft(value)
|
if (!editing) setDraft(value)
|
||||||
}, [value, editing])
|
}, [value, editing])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!renameSignal || disabled) return
|
||||||
|
setEditing(true)
|
||||||
|
}, [renameSignal, disabled])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editing) return
|
if (!editing) return
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import { DocsToolbar } from "@/components/drive/richtext/docs-toolbar"
|
|||||||
import { buildRichTextExtensions, RICHTEXT_EDITOR_CLASS } from "@/lib/drive/richtext-extensions"
|
import { buildRichTextExtensions, RICHTEXT_EDITOR_CLASS } from "@/lib/drive/richtext-extensions"
|
||||||
import type { RichTextSaveStatus, RichTextSessionResponse } from "@/lib/drive/richtext-types"
|
import type { RichTextSaveStatus, RichTextSessionResponse } from "@/lib/drive/richtext-types"
|
||||||
import type { DriveShare, DriveFileInfo } from "@/lib/api/types"
|
import type { DriveShare, DriveFileInfo } from "@/lib/api/types"
|
||||||
|
import { useDocsEditMenu } from "@/lib/drive/use-docs-edit-menu"
|
||||||
|
import { useDocsFileMenu } from "@/lib/drive/use-docs-file-menu"
|
||||||
import { useDocsViewSettings } from "@/lib/drive/docs-view-settings"
|
import { useDocsViewSettings } from "@/lib/drive/docs-view-settings"
|
||||||
import { useCollabPresence } from "@/lib/drive/use-collab-presence"
|
import { useCollabPresence } from "@/lib/drive/use-collab-presence"
|
||||||
import { apiClient } from "@/lib/api/client"
|
import { apiClient } from "@/lib/api/client"
|
||||||
@ -35,6 +37,9 @@ export type RichTextDocsChromeProps = {
|
|||||||
trailing?: ReactNode
|
trailing?: ReactNode
|
||||||
moveFile?: DriveFileInfo
|
moveFile?: DriveFileInfo
|
||||||
onFileMoved?: (newPath: string) => void
|
onFileMoved?: (newPath: string) => void
|
||||||
|
file?: DriveFileInfo
|
||||||
|
onRenameRequest?: () => void
|
||||||
|
renameSignal?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RichTextDocumentEditor({
|
export function RichTextDocumentEditor({
|
||||||
@ -255,6 +260,34 @@ export function RichTextDocumentEditor({
|
|||||||
}
|
}
|
||||||
}, [editor, settings.spellcheck])
|
}, [editor, settings.spellcheck])
|
||||||
|
|
||||||
|
const fileMenu = useDocsFileMenu({
|
||||||
|
file: chrome?.file,
|
||||||
|
editor,
|
||||||
|
pageFormatId: settings.pageFormatId,
|
||||||
|
onPageFormatChange: setPageFormatId,
|
||||||
|
onShareClick: chrome?.onShareClick,
|
||||||
|
onRenameRequest: chrome?.onRenameRequest,
|
||||||
|
onFileMoved: chrome?.onFileMoved,
|
||||||
|
disabled: !editable,
|
||||||
|
})
|
||||||
|
|
||||||
|
const editMenu = useDocsEditMenu({
|
||||||
|
editor,
|
||||||
|
disabled: !editable,
|
||||||
|
})
|
||||||
|
|
||||||
|
const chromeProps = chrome
|
||||||
|
? {
|
||||||
|
...chrome,
|
||||||
|
fileMenuActions: fileMenu.actions,
|
||||||
|
fileMenuDialogs: fileMenu.dialogs,
|
||||||
|
fileMenuDisabled: fileMenu.disabled,
|
||||||
|
editMenuActions: editMenu.actions,
|
||||||
|
editMenuState: editMenu.state,
|
||||||
|
editMenuDisabled: editMenu.disabled,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor || collaboration || !importDone || session.importRequired) return
|
if (!editor || collaboration || !importDone || session.importRequired) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@ -296,9 +329,9 @@ export function RichTextDocumentEditor({
|
|||||||
: "Connexion…"
|
: "Connexion…"
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{chrome && !settings.chromeCollapsed ? (
|
{chromeProps && !settings.chromeCollapsed ? (
|
||||||
<DocsChrome
|
<DocsChrome
|
||||||
{...chrome}
|
{...chromeProps}
|
||||||
saveStatus={saveStatus}
|
saveStatus={saveStatus}
|
||||||
presenceUsers={presenceUsers}
|
presenceUsers={presenceUsers}
|
||||||
pageFormatId={settings.pageFormatId}
|
pageFormatId={settings.pageFormatId}
|
||||||
@ -316,9 +349,9 @@ export function RichTextDocumentEditor({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-background">
|
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-background">
|
||||||
{chrome && !settings.chromeCollapsed ? (
|
{chromeProps && !settings.chromeCollapsed ? (
|
||||||
<DocsChrome
|
<DocsChrome
|
||||||
{...chrome}
|
{...chromeProps}
|
||||||
saveStatus={saveStatus}
|
saveStatus={saveStatus}
|
||||||
presenceUsers={presenceUsers}
|
presenceUsers={presenceUsers}
|
||||||
pageFormatId={settings.pageFormatId}
|
pageFormatId={settings.pageFormatId}
|
||||||
|
|||||||
@ -38,6 +38,7 @@ export function RichTextEditor({ fileId }: { fileId: string }) {
|
|||||||
const displayPath = file?.path ?? ""
|
const displayPath = file?.path ?? ""
|
||||||
const [session, setSession] = useState<RichTextSessionResponse | null>(null)
|
const [session, setSession] = useState<RichTextSessionResponse | null>(null)
|
||||||
const [sessionError, setSessionError] = useState<string | null>(null)
|
const [sessionError, setSessionError] = useState<string | null>(null)
|
||||||
|
const [renameSignal, setRenameSignal] = useState(0)
|
||||||
|
|
||||||
const fileName = file?.name ?? fileNameFromPath(displayPath)
|
const fileName = file?.name ?? fileNameFromPath(displayPath)
|
||||||
const title = displayFileBaseName(fileName)
|
const title = displayFileBaseName(fileName)
|
||||||
@ -134,6 +135,9 @@ export function RichTextEditor({ fileId }: { fileId: string }) {
|
|||||||
showAccount: true,
|
showAccount: true,
|
||||||
moveFile,
|
moveFile,
|
||||||
onFileMoved: handleFileMoved,
|
onFileMoved: handleFileMoved,
|
||||||
|
file: moveFile,
|
||||||
|
onRenameRequest: () => setRenameSignal((value) => value + 1),
|
||||||
|
renameSignal,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
title,
|
title,
|
||||||
@ -144,6 +148,7 @@ export function RichTextEditor({ fileId }: { fileId: string }) {
|
|||||||
openShare,
|
openShare,
|
||||||
moveFile,
|
moveFile,
|
||||||
handleFileMoved,
|
handleFileMoved,
|
||||||
|
renameSignal,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import { ShareDialog } from "@/components/drive/share-dialog"
|
|||||||
import { CollabPresenceAvatars } from "@/components/drive/richtext/collab-presence-avatars"
|
import { CollabPresenceAvatars } from "@/components/drive/richtext/collab-presence-avatars"
|
||||||
import { DocsLogoIcon } from "@/components/drive/richtext/docs-logo-icon"
|
import { DocsLogoIcon } from "@/components/drive/richtext/docs-logo-icon"
|
||||||
import { DocsMenubar } from "@/components/drive/richtext/docs-menubar"
|
import { DocsMenubar } from "@/components/drive/richtext/docs-menubar"
|
||||||
|
import type { DocsEditMenuActions, DocsEditMenuState } from "@/components/drive/richtext/docs-edit-menu"
|
||||||
|
import type { DocsFileMenuActions } from "@/components/drive/richtext/docs-file-menu"
|
||||||
import { DocsMoveButton } from "@/components/drive/richtext/docs-move-button"
|
import { DocsMoveButton } from "@/components/drive/richtext/docs-move-button"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import type { DriveShare, DriveFileInfo } from "@/lib/api/types"
|
import type { DriveShare, DriveFileInfo } from "@/lib/api/types"
|
||||||
@ -60,6 +62,13 @@ export function DocsChrome({
|
|||||||
trailing,
|
trailing,
|
||||||
moveFile,
|
moveFile,
|
||||||
onFileMoved,
|
onFileMoved,
|
||||||
|
fileMenuActions,
|
||||||
|
fileMenuDialogs,
|
||||||
|
fileMenuDisabled,
|
||||||
|
editMenuActions,
|
||||||
|
editMenuState,
|
||||||
|
editMenuDisabled,
|
||||||
|
renameSignal,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
onRename?: (next: string) => Promise<void>
|
onRename?: (next: string) => Promise<void>
|
||||||
@ -81,6 +90,13 @@ export function DocsChrome({
|
|||||||
/** Propriétaire uniquement — affiche le bouton déplacer. */
|
/** Propriétaire uniquement — affiche le bouton déplacer. */
|
||||||
moveFile?: DriveFileInfo
|
moveFile?: DriveFileInfo
|
||||||
onFileMoved?: (newPath: string) => void
|
onFileMoved?: (newPath: string) => void
|
||||||
|
fileMenuActions?: DocsFileMenuActions
|
||||||
|
fileMenuDialogs?: ReactNode
|
||||||
|
fileMenuDisabled?: boolean
|
||||||
|
editMenuActions?: DocsEditMenuActions
|
||||||
|
editMenuState?: DocsEditMenuState
|
||||||
|
editMenuDisabled?: boolean
|
||||||
|
renameSignal?: number
|
||||||
}) {
|
}) {
|
||||||
const shareIcon = resolveShareButtonIcon(shares)
|
const shareIcon = resolveShareButtonIcon(shares)
|
||||||
const statusText = saveStatusLabel(saveStatus)
|
const statusText = saveStatusLabel(saveStatus)
|
||||||
@ -112,6 +128,7 @@ export function DocsChrome({
|
|||||||
value={title}
|
value={title}
|
||||||
onRename={onRename}
|
onRename={onRename}
|
||||||
disabled={renameDisabled}
|
disabled={renameDisabled}
|
||||||
|
renameSignal={renameSignal}
|
||||||
className="pr-1 text-base font-normal"
|
className="pr-1 text-base font-normal"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@ -144,13 +161,18 @@ export function DocsChrome({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="-mt-1 flex min-w-0 items-center overflow-hidden">
|
<div className="-mt-1 flex min-w-0 items-center overflow-x-auto overflow-y-visible">
|
||||||
<DocsMenubar
|
<DocsMenubar
|
||||||
className="docs-menubar shrink-0"
|
className="docs-menubar shrink-0"
|
||||||
pageFormatId={pageFormatId}
|
pageFormatId={pageFormatId}
|
||||||
onPageFormatChange={onPageFormatChange}
|
onPageFormatChange={onPageFormatChange}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
onZoomChange={onZoomChange}
|
onZoomChange={onZoomChange}
|
||||||
|
fileMenuActions={fileMenuActions}
|
||||||
|
fileMenuDisabled={fileMenuDisabled}
|
||||||
|
editMenuActions={editMenuActions}
|
||||||
|
editMenuState={editMenuState}
|
||||||
|
editMenuDisabled={editMenuDisabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -180,6 +202,7 @@ export function DocsChrome({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{showShare ? <ShareDialog /> : null}
|
{showShare ? <ShareDialog /> : null}
|
||||||
|
{fileMenuDialogs}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
80
components/drive/richtext/docs-details-dialog.tsx
Normal file
80
components/drive/richtext/docs-details-dialog.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import type { DriveFileInfo } from "@/lib/api/types"
|
||||||
|
import { displayFileBaseName, displayFileName } from "@/lib/drive/display-file-name"
|
||||||
|
import {
|
||||||
|
DRIVE_DIALOG_BODY,
|
||||||
|
DRIVE_DIALOG_CONTENT,
|
||||||
|
DRIVE_DIALOG_HEADER,
|
||||||
|
DRIVE_DIALOG_OVERLAY,
|
||||||
|
DRIVE_TEXT_SECONDARY,
|
||||||
|
DRIVE_TEXT_TITLE,
|
||||||
|
} from "@/lib/drive/drive-dialog-styles"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function formatBytes(size: number): string {
|
||||||
|
if (size < 1024) return `${size} o`
|
||||||
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} Ko`
|
||||||
|
return `${(size / (1024 * 1024)).toFixed(1)} Mo`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return iso
|
||||||
|
return date.toLocaleString("fr-FR", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[120px_1fr] gap-3 py-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="break-all">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocsDetailsDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
file,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
file: DriveFileInfo
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
overlayClassName={DRIVE_DIALOG_OVERLAY}
|
||||||
|
className={cn(DRIVE_DIALOG_CONTENT, "sm:max-w-[480px]")}
|
||||||
|
>
|
||||||
|
<DialogHeader className={DRIVE_DIALOG_HEADER}>
|
||||||
|
<DialogTitle className={cn("text-base font-medium", DRIVE_TEXT_TITLE)}>
|
||||||
|
Détails
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className={cn("text-sm", DRIVE_TEXT_SECONDARY)}>
|
||||||
|
Informations sur le document.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className={cn(DRIVE_DIALOG_BODY, "divide-y divide-border")}>
|
||||||
|
<DetailRow label="Nom" value={displayFileBaseName(file.name)} />
|
||||||
|
<DetailRow label="Fichier" value={displayFileName(file.name)} />
|
||||||
|
<DetailRow label="Emplacement" value={file.path} />
|
||||||
|
<DetailRow label="Type" value={file.mime_type || "—"} />
|
||||||
|
<DetailRow label="Taille" value={formatBytes(file.size)} />
|
||||||
|
<DetailRow label="Modifié" value={formatDate(file.last_modified)} />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
145
components/drive/richtext/docs-edit-menu.tsx
Normal file
145
components/drive/richtext/docs-edit-menu.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import {
|
||||||
|
ClipboardPaste,
|
||||||
|
ClipboardX,
|
||||||
|
Copy,
|
||||||
|
Redo2,
|
||||||
|
ScanSearch,
|
||||||
|
Scissors,
|
||||||
|
SquareDashed,
|
||||||
|
Trash2,
|
||||||
|
Undo2,
|
||||||
|
} from "lucide-react"
|
||||||
|
import {
|
||||||
|
MenubarContent,
|
||||||
|
MenubarItem,
|
||||||
|
MenubarMenu,
|
||||||
|
MenubarSeparator,
|
||||||
|
MenubarTrigger,
|
||||||
|
} from "@/components/ui/menubar"
|
||||||
|
import { DocsMenuShortcut } from "@/components/drive/richtext/docs-menu-shortcut"
|
||||||
|
import { DOCS_MENUBAR_CONTENT_PROPS } from "@/components/drive/richtext/docs-menubar-props"
|
||||||
|
|
||||||
|
export type DocsEditMenuActions = {
|
||||||
|
onUndo: () => void
|
||||||
|
onRedo: () => void
|
||||||
|
onCut: () => void
|
||||||
|
onCopy: () => void
|
||||||
|
onPaste: () => void
|
||||||
|
onPastePlain: () => void
|
||||||
|
onSelectAll: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
onFindReplace: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocsEditMenuState = {
|
||||||
|
canUndo: boolean
|
||||||
|
canRedo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuIcon({ children }: { children: ReactNode }) {
|
||||||
|
return <span className="docs-menu-item-icon">{children}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocsEditMenu({
|
||||||
|
actions,
|
||||||
|
state,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
actions: DocsEditMenuActions
|
||||||
|
state: DocsEditMenuState
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenubarMenu>
|
||||||
|
<MenubarTrigger className="docs-menu-trigger">Édition</MenubarTrigger>
|
||||||
|
<MenubarContent
|
||||||
|
{...DOCS_MENUBAR_CONTENT_PROPS}
|
||||||
|
className="docs-menu-content min-w-[300px] overflow-visible"
|
||||||
|
data-docs-menu-surface
|
||||||
|
>
|
||||||
|
<MenubarItem
|
||||||
|
className="docs-menu-item"
|
||||||
|
disabled={disabled || !state.canUndo}
|
||||||
|
onClick={actions.onUndo}
|
||||||
|
>
|
||||||
|
<MenuIcon>
|
||||||
|
<Undo2 className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Annuler
|
||||||
|
<DocsMenuShortcut shortcutId="edit.undo" />
|
||||||
|
</MenubarItem>
|
||||||
|
<MenubarItem
|
||||||
|
className="docs-menu-item"
|
||||||
|
disabled={disabled || !state.canRedo}
|
||||||
|
onClick={actions.onRedo}
|
||||||
|
>
|
||||||
|
<MenuIcon>
|
||||||
|
<Redo2 className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Rétablir
|
||||||
|
<DocsMenuShortcut shortcutId="edit.redo" />
|
||||||
|
</MenubarItem>
|
||||||
|
|
||||||
|
<MenubarSeparator />
|
||||||
|
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onCut}>
|
||||||
|
<MenuIcon>
|
||||||
|
<Scissors className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Couper
|
||||||
|
<DocsMenuShortcut shortcutId="edit.cut" />
|
||||||
|
</MenubarItem>
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onCopy}>
|
||||||
|
<MenuIcon>
|
||||||
|
<Copy className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Copier
|
||||||
|
<DocsMenuShortcut shortcutId="edit.copy" />
|
||||||
|
</MenubarItem>
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onPaste}>
|
||||||
|
<MenuIcon>
|
||||||
|
<ClipboardPaste className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Coller
|
||||||
|
<DocsMenuShortcut shortcutId="edit.paste" />
|
||||||
|
</MenubarItem>
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onPastePlain}>
|
||||||
|
<MenuIcon>
|
||||||
|
<ClipboardX className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Coller sans la mise en forme
|
||||||
|
<DocsMenuShortcut shortcutId="edit.pastePlain" />
|
||||||
|
</MenubarItem>
|
||||||
|
|
||||||
|
<MenubarSeparator />
|
||||||
|
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onSelectAll}>
|
||||||
|
<MenuIcon>
|
||||||
|
<SquareDashed className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Tout sélectionner
|
||||||
|
<DocsMenuShortcut shortcutId="edit.selectAll" />
|
||||||
|
</MenubarItem>
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onDelete}>
|
||||||
|
<MenuIcon>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Supprimer
|
||||||
|
</MenubarItem>
|
||||||
|
|
||||||
|
<MenubarSeparator />
|
||||||
|
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onFindReplace}>
|
||||||
|
<MenuIcon>
|
||||||
|
<ScanSearch className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Rechercher et remplacer
|
||||||
|
<DocsMenuShortcut shortcutId="edit.findReplace" />
|
||||||
|
</MenubarItem>
|
||||||
|
</MenubarContent>
|
||||||
|
</MenubarMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
295
components/drive/richtext/docs-file-menu.tsx
Normal file
295
components/drive/richtext/docs-file-menu.tsx
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
FolderInput,
|
||||||
|
FolderOpen,
|
||||||
|
Globe2,
|
||||||
|
HardDrive,
|
||||||
|
History,
|
||||||
|
Info,
|
||||||
|
Mail,
|
||||||
|
Pencil,
|
||||||
|
Printer,
|
||||||
|
Share2,
|
||||||
|
Shield,
|
||||||
|
Trash2,
|
||||||
|
UserPlus,
|
||||||
|
} from "lucide-react"
|
||||||
|
import {
|
||||||
|
MenubarContent,
|
||||||
|
MenubarItem,
|
||||||
|
MenubarMenu,
|
||||||
|
MenubarSeparator,
|
||||||
|
MenubarSub,
|
||||||
|
MenubarSubContent,
|
||||||
|
MenubarSubTrigger,
|
||||||
|
MenubarTrigger,
|
||||||
|
} from "@/components/ui/menubar"
|
||||||
|
import { DOCS_MENUBAR_CONTENT_PROPS } from "@/components/drive/richtext/docs-menubar-props"
|
||||||
|
import { DocsLogoIcon } from "@/components/drive/richtext/docs-logo-icon"
|
||||||
|
import { DocsMenuShortcut } from "@/components/drive/richtext/docs-menu-shortcut"
|
||||||
|
import { DOCS_DOWNLOAD_FORMATS, type DocsDownloadFormat } from "@/lib/drive/docs-file-menu-export"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export type DocsFileMenuActions = {
|
||||||
|
onNewDocument: () => void
|
||||||
|
onNewFromTemplate: () => void
|
||||||
|
onOpen: () => void
|
||||||
|
onMakeCopy: () => void
|
||||||
|
onShareWithUsers: () => void
|
||||||
|
onPublishToWeb: () => void
|
||||||
|
onEmailFile: () => void
|
||||||
|
onEmailCollaborators: () => void
|
||||||
|
onEmailDraft: () => void
|
||||||
|
onDownload: (format: DocsDownloadFormat) => void
|
||||||
|
onRename: () => void
|
||||||
|
onMove: () => void
|
||||||
|
onAddShortcut: () => void
|
||||||
|
onMoveToTrash: () => void
|
||||||
|
onNameCurrentVersion: () => void
|
||||||
|
onShowVersionHistory: () => void
|
||||||
|
onToggleOffline: () => void
|
||||||
|
onDetails: () => void
|
||||||
|
onSecurityLimits: () => void
|
||||||
|
onPageSetup: () => void
|
||||||
|
onPrint: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuIcon({ children }: { children: ReactNode }) {
|
||||||
|
return <span className="docs-menu-item-icon">{children}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocsFileMenu({
|
||||||
|
actions,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
actions: DocsFileMenuActions
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenubarMenu>
|
||||||
|
<MenubarTrigger className="docs-menu-trigger">Fichier</MenubarTrigger>
|
||||||
|
<MenubarContent
|
||||||
|
{...DOCS_MENUBAR_CONTENT_PROPS}
|
||||||
|
className="docs-menu-content min-w-[280px] overflow-visible"
|
||||||
|
data-docs-menu-surface
|
||||||
|
>
|
||||||
|
<MenubarSub>
|
||||||
|
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
|
||||||
|
<MenuIcon>
|
||||||
|
<FileText className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Nouveau
|
||||||
|
</MenubarSubTrigger>
|
||||||
|
<MenubarSubContent className="docs-menu-content docs-menu-sub-content min-w-[240px] overflow-visible" data-docs-menu-surface>
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onNewDocument}>
|
||||||
|
<MenuIcon>
|
||||||
|
<DocsLogoIcon className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Document
|
||||||
|
</MenubarItem>
|
||||||
|
<MenubarItem
|
||||||
|
className="docs-menu-item"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={actions.onNewFromTemplate}
|
||||||
|
>
|
||||||
|
<MenuIcon>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
À partir de la galerie de modèles
|
||||||
|
</MenubarItem>
|
||||||
|
</MenubarSubContent>
|
||||||
|
</MenubarSub>
|
||||||
|
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onOpen}>
|
||||||
|
<MenuIcon>
|
||||||
|
<FolderOpen className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Ouvrir
|
||||||
|
<DocsMenuShortcut shortcutId="file.open" />
|
||||||
|
</MenubarItem>
|
||||||
|
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onMakeCopy}>
|
||||||
|
<MenuIcon>
|
||||||
|
<Copy className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Créer une copie
|
||||||
|
</MenubarItem>
|
||||||
|
|
||||||
|
<MenubarSeparator />
|
||||||
|
|
||||||
|
<MenubarSub>
|
||||||
|
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
|
||||||
|
<MenuIcon>
|
||||||
|
<UserPlus className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Partager
|
||||||
|
</MenubarSubTrigger>
|
||||||
|
<MenubarSubContent className="docs-menu-content docs-menu-sub-content min-w-[260px] overflow-visible" data-docs-menu-surface>
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onShareWithUsers}>
|
||||||
|
<MenuIcon>
|
||||||
|
<Share2 className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Partager avec d'autres utilisateurs
|
||||||
|
</MenubarItem>
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onPublishToWeb}>
|
||||||
|
<MenuIcon>
|
||||||
|
<Globe2 className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Publier sur le Web
|
||||||
|
</MenubarItem>
|
||||||
|
</MenubarSubContent>
|
||||||
|
</MenubarSub>
|
||||||
|
|
||||||
|
<MenubarSub>
|
||||||
|
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
|
||||||
|
<MenuIcon>
|
||||||
|
<Mail className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Envoyer par e-mail
|
||||||
|
</MenubarSubTrigger>
|
||||||
|
<MenubarSubContent className="docs-menu-content docs-menu-sub-content min-w-[280px] overflow-visible" data-docs-menu-surface>
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onEmailFile}>
|
||||||
|
Envoyer ce fichier par e-mail
|
||||||
|
</MenubarItem>
|
||||||
|
<MenubarItem
|
||||||
|
className="docs-menu-item"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={actions.onEmailCollaborators}
|
||||||
|
>
|
||||||
|
Envoyer par e-mail aux collaborateurs
|
||||||
|
</MenubarItem>
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onEmailDraft}>
|
||||||
|
Brouillon d'e-mail
|
||||||
|
</MenubarItem>
|
||||||
|
</MenubarSubContent>
|
||||||
|
</MenubarSub>
|
||||||
|
|
||||||
|
<MenubarSub>
|
||||||
|
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
|
||||||
|
<MenuIcon>
|
||||||
|
<Download className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Télécharger
|
||||||
|
</MenubarSubTrigger>
|
||||||
|
<MenubarSubContent className="docs-menu-content docs-menu-sub-content min-w-[280px] overflow-visible" data-docs-menu-surface>
|
||||||
|
{DOCS_DOWNLOAD_FORMATS.map((format) => (
|
||||||
|
<MenubarItem
|
||||||
|
key={format.id}
|
||||||
|
className="docs-menu-item"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => actions.onDownload(format.id)}
|
||||||
|
>
|
||||||
|
{format.label}
|
||||||
|
</MenubarItem>
|
||||||
|
))}
|
||||||
|
</MenubarSubContent>
|
||||||
|
</MenubarSub>
|
||||||
|
|
||||||
|
<MenubarSeparator />
|
||||||
|
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onRename}>
|
||||||
|
<MenuIcon>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Renommer
|
||||||
|
</MenubarItem>
|
||||||
|
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onMove}>
|
||||||
|
<MenuIcon>
|
||||||
|
<FolderInput className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Déplacer
|
||||||
|
</MenubarItem>
|
||||||
|
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onAddShortcut}>
|
||||||
|
<MenuIcon>
|
||||||
|
<HardDrive className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Ajouter un raccourci dans Drive
|
||||||
|
</MenubarItem>
|
||||||
|
|
||||||
|
<MenubarItem
|
||||||
|
className={cn("docs-menu-item", "focus:text-destructive")}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={actions.onMoveToTrash}
|
||||||
|
>
|
||||||
|
<MenuIcon>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Placer dans la corbeille
|
||||||
|
</MenubarItem>
|
||||||
|
|
||||||
|
<MenubarSeparator />
|
||||||
|
|
||||||
|
<MenubarSub>
|
||||||
|
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
|
||||||
|
<MenuIcon>
|
||||||
|
<History className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Historique des versions
|
||||||
|
</MenubarSubTrigger>
|
||||||
|
<MenubarSubContent className="docs-menu-content docs-menu-sub-content min-w-[260px] overflow-visible" data-docs-menu-surface>
|
||||||
|
<MenubarItem
|
||||||
|
className="docs-menu-item"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={actions.onNameCurrentVersion}
|
||||||
|
>
|
||||||
|
Nommer la version actuelle
|
||||||
|
</MenubarItem>
|
||||||
|
<MenubarItem
|
||||||
|
className="docs-menu-item"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={actions.onShowVersionHistory}
|
||||||
|
>
|
||||||
|
Afficher l'historique des versions
|
||||||
|
</MenubarItem>
|
||||||
|
</MenubarSubContent>
|
||||||
|
</MenubarSub>
|
||||||
|
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onToggleOffline}>
|
||||||
|
<MenuIcon>
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Rendre disponible hors connexion
|
||||||
|
</MenubarItem>
|
||||||
|
|
||||||
|
<MenubarSeparator />
|
||||||
|
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onDetails}>
|
||||||
|
<MenuIcon>
|
||||||
|
<Info className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Détails
|
||||||
|
</MenubarItem>
|
||||||
|
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onSecurityLimits}>
|
||||||
|
<MenuIcon>
|
||||||
|
<Shield className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Limites de sécurité
|
||||||
|
</MenubarItem>
|
||||||
|
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onPageSetup}>
|
||||||
|
<MenuIcon>
|
||||||
|
<FileText className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Configuration de la page
|
||||||
|
</MenubarItem>
|
||||||
|
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onPrint}>
|
||||||
|
<MenuIcon>
|
||||||
|
<Printer className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Imprimer
|
||||||
|
<DocsMenuShortcut shortcutId="file.print" />
|
||||||
|
</MenubarItem>
|
||||||
|
</MenubarContent>
|
||||||
|
</MenubarMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
components/drive/richtext/docs-menu-shortcut.tsx
Normal file
12
components/drive/richtext/docs-menu-shortcut.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { MenubarShortcut } from "@/components/ui/menubar"
|
||||||
|
import type { DocsShortcutId } from "@/lib/drive/docs-keyboard-shortcuts-config"
|
||||||
|
import { useDocsKeyboardShortcutsStore } from "@/lib/stores/docs-keyboard-shortcuts-store"
|
||||||
|
|
||||||
|
export function DocsMenuShortcut({ shortcutId }: { shortcutId: DocsShortcutId }) {
|
||||||
|
const label = useDocsKeyboardShortcutsStore((state) => state.formatShortcut(shortcutId))
|
||||||
|
|
||||||
|
if (!label) return null
|
||||||
|
return <MenubarShortcut>{label}</MenubarShortcut>
|
||||||
|
}
|
||||||
6
components/drive/richtext/docs-menubar-props.ts
Normal file
6
components/drive/richtext/docs-menubar-props.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/** Flush dropdown with trigger hover background (Google Docs style). */
|
||||||
|
export const DOCS_MENUBAR_CONTENT_PROPS = {
|
||||||
|
align: "start" as const,
|
||||||
|
sideOffset: 0,
|
||||||
|
alignOffset: 0,
|
||||||
|
} as const
|
||||||
@ -1,5 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { DocsEditMenu, type DocsEditMenuActions, type DocsEditMenuState } from "@/components/drive/richtext/docs-edit-menu"
|
||||||
|
import { DocsFileMenu, type DocsFileMenuActions } from "@/components/drive/richtext/docs-file-menu"
|
||||||
|
import { DOCS_MENUBAR_CONTENT_PROPS } from "@/components/drive/richtext/docs-menubar-props"
|
||||||
import {
|
import {
|
||||||
Menubar,
|
Menubar,
|
||||||
MenubarContent,
|
MenubarContent,
|
||||||
@ -11,9 +14,7 @@ import {
|
|||||||
import { PAGE_FORMATS, type PageFormatId } from "@/lib/drive/page-formats"
|
import { PAGE_FORMATS, type PageFormatId } from "@/lib/drive/page-formats"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const MENU_LABELS = [
|
const OTHER_MENU_LABELS = [
|
||||||
"Fichier",
|
|
||||||
"Édition",
|
|
||||||
"Affichage",
|
"Affichage",
|
||||||
"Insertion",
|
"Insertion",
|
||||||
"Format",
|
"Format",
|
||||||
@ -26,12 +27,22 @@ export function DocsMenubar({
|
|||||||
onPageFormatChange,
|
onPageFormatChange,
|
||||||
zoom,
|
zoom,
|
||||||
onZoomChange,
|
onZoomChange,
|
||||||
|
fileMenuActions,
|
||||||
|
fileMenuDisabled,
|
||||||
|
editMenuActions,
|
||||||
|
editMenuState,
|
||||||
|
editMenuDisabled,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
pageFormatId: PageFormatId
|
pageFormatId: PageFormatId
|
||||||
onPageFormatChange: (id: PageFormatId) => void
|
onPageFormatChange: (id: PageFormatId) => void
|
||||||
zoom: number
|
zoom: number
|
||||||
onZoomChange: (zoom: number) => void
|
onZoomChange: (zoom: number) => void
|
||||||
|
fileMenuActions?: DocsFileMenuActions
|
||||||
|
fileMenuDisabled?: boolean
|
||||||
|
editMenuActions?: DocsEditMenuActions
|
||||||
|
editMenuState?: DocsEditMenuState
|
||||||
|
editMenuDisabled?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -41,12 +52,46 @@ export function DocsMenubar({
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{MENU_LABELS.map((label) => {
|
{fileMenuActions ? (
|
||||||
|
<DocsFileMenu actions={fileMenuActions} disabled={fileMenuDisabled} />
|
||||||
|
) : (
|
||||||
|
<MenubarMenu>
|
||||||
|
<MenubarTrigger className="docs-menu-trigger">Fichier</MenubarTrigger>
|
||||||
|
<MenubarContent {...DOCS_MENUBAR_CONTENT_PROPS} data-docs-menu-surface>
|
||||||
|
<MenubarItem disabled className="text-muted-foreground">
|
||||||
|
Bientôt disponible
|
||||||
|
</MenubarItem>
|
||||||
|
</MenubarContent>
|
||||||
|
</MenubarMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editMenuActions && editMenuState ? (
|
||||||
|
<DocsEditMenu
|
||||||
|
actions={editMenuActions}
|
||||||
|
state={editMenuState}
|
||||||
|
disabled={editMenuDisabled}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MenubarMenu>
|
||||||
|
<MenubarTrigger className="docs-menu-trigger">Édition</MenubarTrigger>
|
||||||
|
<MenubarContent {...DOCS_MENUBAR_CONTENT_PROPS} data-docs-menu-surface>
|
||||||
|
<MenubarItem disabled className="text-muted-foreground">
|
||||||
|
Bientôt disponible
|
||||||
|
</MenubarItem>
|
||||||
|
</MenubarContent>
|
||||||
|
</MenubarMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{OTHER_MENU_LABELS.map((label) => {
|
||||||
if (label === "Affichage") {
|
if (label === "Affichage") {
|
||||||
return (
|
return (
|
||||||
<MenubarMenu key={label}>
|
<MenubarMenu key={label}>
|
||||||
<MenubarTrigger className="docs-menu-trigger">{label}</MenubarTrigger>
|
<MenubarTrigger className="docs-menu-trigger">{label}</MenubarTrigger>
|
||||||
<MenubarContent align="start" className="min-w-52">
|
<MenubarContent
|
||||||
|
{...DOCS_MENUBAR_CONTENT_PROPS}
|
||||||
|
className="docs-menu-content min-w-52 overflow-visible"
|
||||||
|
data-docs-menu-surface
|
||||||
|
>
|
||||||
<MenubarItem disabled className="text-xs text-muted-foreground">
|
<MenubarItem disabled className="text-xs text-muted-foreground">
|
||||||
Mode (bientôt)
|
Mode (bientôt)
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
@ -87,7 +132,11 @@ export function DocsMenubar({
|
|||||||
return (
|
return (
|
||||||
<MenubarMenu key={label}>
|
<MenubarMenu key={label}>
|
||||||
<MenubarTrigger className="docs-menu-trigger">{label}</MenubarTrigger>
|
<MenubarTrigger className="docs-menu-trigger">{label}</MenubarTrigger>
|
||||||
<MenubarContent align="start">
|
<MenubarContent
|
||||||
|
{...DOCS_MENUBAR_CONTENT_PROPS}
|
||||||
|
className="docs-menu-content overflow-visible"
|
||||||
|
data-docs-menu-surface
|
||||||
|
>
|
||||||
<MenubarItem disabled className="text-muted-foreground">
|
<MenubarItem disabled className="text-muted-foreground">
|
||||||
Bientôt disponible
|
Bientôt disponible
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
|
|||||||
158
components/drive/richtext/docs-open-dialog.tsx
Normal file
158
components/drive/richtext/docs-open-dialog.tsx
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { ChevronRight, FileText, Folder, Loader2 } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { useDriveList } from "@/lib/api/hooks/use-drive-queries"
|
||||||
|
import type { DriveFileInfo } from "@/lib/api/types"
|
||||||
|
import { displayFileBaseName, displayFileName } from "@/lib/drive/display-file-name"
|
||||||
|
import {
|
||||||
|
DRIVE_BTN_GHOST,
|
||||||
|
DRIVE_DIALOG_CONTENT,
|
||||||
|
DRIVE_DIALOG_DIVIDER,
|
||||||
|
DRIVE_DIALOG_OVERLAY,
|
||||||
|
DRIVE_TEXT_PRIMARY,
|
||||||
|
DRIVE_TEXT_SECONDARY,
|
||||||
|
DRIVE_TEXT_TITLE,
|
||||||
|
} from "@/lib/drive/drive-dialog-styles"
|
||||||
|
import { normalizeDriveFolderPath } from "@/lib/drive/drive-sidebar-tree"
|
||||||
|
import { isRichTextFile } from "@/lib/drive/richtext-formats"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export function DocsOpenDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onOpenFile,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onOpenFile: (file: DriveFileInfo) => void | Promise<void>
|
||||||
|
}) {
|
||||||
|
const [browsePath, setBrowsePath] = useState("/")
|
||||||
|
const [openingPath, setOpeningPath] = useState<string | null>(null)
|
||||||
|
const list = useDriveList(browsePath, 1, "", open)
|
||||||
|
|
||||||
|
const folders = useMemo(
|
||||||
|
() => (list.data?.files ?? []).filter((f) => f.type === "directory"),
|
||||||
|
[list.data?.files]
|
||||||
|
)
|
||||||
|
|
||||||
|
const documents = useMemo(
|
||||||
|
() => (list.data?.files ?? []).filter((f) => f.type === "file" && isRichTextFile(f)),
|
||||||
|
[list.data?.files]
|
||||||
|
)
|
||||||
|
|
||||||
|
const crumbs = useMemo(() => {
|
||||||
|
const normalized = normalizeDriveFolderPath(browsePath)
|
||||||
|
if (normalized === "/") return [{ path: "/", label: "Mon Drive" }]
|
||||||
|
const parts = normalized.slice(1).split("/")
|
||||||
|
const out: { path: string; label: string }[] = [{ path: "/", label: "Mon Drive" }]
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const path = "/" + parts.slice(0, i + 1).join("/")
|
||||||
|
out.push({ path, label: displayFileName(parts[i]!) })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [browsePath])
|
||||||
|
|
||||||
|
const openFile = async (file: DriveFileInfo) => {
|
||||||
|
setOpeningPath(file.path)
|
||||||
|
try {
|
||||||
|
await onOpenFile(file)
|
||||||
|
onOpenChange(false)
|
||||||
|
} finally {
|
||||||
|
setOpeningPath(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
overlayClassName={DRIVE_DIALOG_OVERLAY}
|
||||||
|
className={cn(DRIVE_DIALOG_CONTENT, "flex max-h-[min(80vh,560px)] flex-col gap-0 sm:max-w-[480px]")}
|
||||||
|
>
|
||||||
|
<DialogHeader className="shrink-0 px-6 pb-3 pt-6">
|
||||||
|
<DialogTitle className={cn("text-base font-medium", DRIVE_TEXT_TITLE)}>
|
||||||
|
Ouvrir un document
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className={cn("text-sm", DRIVE_TEXT_SECONDARY)}>
|
||||||
|
Choisissez un document texte dans votre Drive.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className={cn("shrink-0 px-6 pb-2", DRIVE_DIALOG_DIVIDER)}>
|
||||||
|
<div className="flex flex-wrap items-center gap-1 text-sm">
|
||||||
|
{crumbs.map((crumb, index) => (
|
||||||
|
<span key={crumb.path} className="inline-flex items-center gap-1">
|
||||||
|
{index > 0 ? <ChevronRight className="size-3.5 text-muted-foreground" /> : null}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(DRIVE_BTN_GHOST, "h-7 px-2 text-sm")}
|
||||||
|
onClick={() => setBrowsePath(crumb.path)}
|
||||||
|
>
|
||||||
|
{crumb.label}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
|
||||||
|
{list.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : folders.length === 0 && documents.length === 0 ? (
|
||||||
|
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Aucun document dans ce dossier.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{folders.map((folder) => (
|
||||||
|
<li key={folder.path}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm hover:bg-accent"
|
||||||
|
onClick={() => setBrowsePath(folder.path)}
|
||||||
|
>
|
||||||
|
<Folder className="size-4 shrink-0 text-[#5f6368]" />
|
||||||
|
<span className={cn("truncate", DRIVE_TEXT_PRIMARY)}>
|
||||||
|
{displayFileName(folder.name)}
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{documents.map((file) => (
|
||||||
|
<li key={file.path}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={openingPath === file.path}
|
||||||
|
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm hover:bg-accent disabled:opacity-60"
|
||||||
|
onClick={() => void openFile(file)}
|
||||||
|
>
|
||||||
|
<FileText className="size-4 shrink-0 text-[#1967d2]" />
|
||||||
|
<span className={cn("truncate", DRIVE_TEXT_PRIMARY)}>
|
||||||
|
{displayFileBaseName(file.name)}
|
||||||
|
</span>
|
||||||
|
{openingPath === file.path ? (
|
||||||
|
<Loader2 className="ml-auto size-4 shrink-0 animate-spin text-muted-foreground" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
89
components/drive/richtext/docs-page-setup-dialog.tsx
Normal file
89
components/drive/richtext/docs-page-setup-dialog.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
DRIVE_BTN_GHOST,
|
||||||
|
DRIVE_BTN_PRIMARY,
|
||||||
|
DRIVE_DIALOG_BODY,
|
||||||
|
DRIVE_DIALOG_CONTENT,
|
||||||
|
DRIVE_DIALOG_FOOTER,
|
||||||
|
DRIVE_DIALOG_HEADER,
|
||||||
|
DRIVE_DIALOG_OVERLAY,
|
||||||
|
DRIVE_TEXT_SECONDARY,
|
||||||
|
DRIVE_TEXT_TITLE,
|
||||||
|
} from "@/lib/drive/drive-dialog-styles"
|
||||||
|
import { PAGE_FORMATS, type PageFormatId } from "@/lib/drive/page-formats"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export function DocsPageSetupDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
pageFormatId,
|
||||||
|
onPageFormatChange,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
pageFormatId: PageFormatId
|
||||||
|
onPageFormatChange: (id: PageFormatId) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
overlayClassName={DRIVE_DIALOG_OVERLAY}
|
||||||
|
className={cn(DRIVE_DIALOG_CONTENT, "sm:max-w-[420px]")}
|
||||||
|
>
|
||||||
|
<DialogHeader className={DRIVE_DIALOG_HEADER}>
|
||||||
|
<DialogTitle className={cn("text-base font-medium", DRIVE_TEXT_TITLE)}>
|
||||||
|
Configuration de la page
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className={cn("text-sm", DRIVE_TEXT_SECONDARY)}>
|
||||||
|
Format de page utilisé pour l'affichage et l'impression.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className={cn(DRIVE_DIALOG_BODY, "space-y-1")}>
|
||||||
|
{PAGE_FORMATS.map((format) => (
|
||||||
|
<button
|
||||||
|
key={format.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm hover:bg-accent",
|
||||||
|
pageFormatId === format.id && "bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => onPageFormatChange(format.id)}
|
||||||
|
>
|
||||||
|
<span>{format.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{format.widthMm} × {format.heightMm} mm
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className={DRIVE_DIALOG_FOOTER}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className={DRIVE_BTN_PRIMARY}
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className={DRIVE_BTN_GHOST}
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -139,7 +139,6 @@ function DocsToolbarInner({
|
|||||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [linkOpen, setLinkOpen] = useState(false)
|
const [linkOpen, setLinkOpen] = useState(false)
|
||||||
const [linkUrl, setLinkUrl] = useState("")
|
const [linkUrl, setLinkUrl] = useState("")
|
||||||
const [lastHighlightColor, setLastHighlightColor] = useState<string>(HIGHLIGHT_COLORS[0]!)
|
|
||||||
const toolbarState = useDocsToolbarState(editor)
|
const toolbarState = useDocsToolbarState(editor)
|
||||||
|
|
||||||
const insertImage = useCallback(
|
const insertImage = useCallback(
|
||||||
@ -421,10 +420,9 @@ function DocsToolbarInner({
|
|||||||
<HighlightColorPicker
|
<HighlightColorPicker
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
colors={HIGHLIGHT_COLORS}
|
colors={HIGHLIGHT_COLORS}
|
||||||
currentColor={highlightColor ?? lastHighlightColor}
|
currentColor={highlightColor ?? "transparent"}
|
||||||
isActive={highlightColor != null}
|
isActive={highlightColor != null}
|
||||||
onPick={(color) => {
|
onPick={(color) => {
|
||||||
setLastHighlightColor(color)
|
|
||||||
editor.chain().focus().setHighlight({ color }).run()
|
editor.chain().focus().setHighlight({ color }).run()
|
||||||
}}
|
}}
|
||||||
onClear={() => editor.chain().focus().unsetHighlight().run()}
|
onClear={() => editor.chain().focus().unsetHighlight().run()}
|
||||||
@ -602,7 +600,6 @@ function DocsToolbarInner({
|
|||||||
linkOpen,
|
linkOpen,
|
||||||
linkUrl,
|
linkUrl,
|
||||||
applyLink,
|
applyLink,
|
||||||
lastHighlightColor,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const { containerRef, measureRef, visibleCount, hasOverflow } = useToolbarOverflow(
|
const { containerRef, measureRef, visibleCount, hasOverflow } = useToolbarOverflow(
|
||||||
|
|||||||
@ -245,6 +245,7 @@ function MenubarSubContent({
|
|||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
|
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
|
||||||
return (
|
return (
|
||||||
|
<MenubarPortal>
|
||||||
<MenubarPrimitive.SubContent
|
<MenubarPrimitive.SubContent
|
||||||
data-slot="menubar-sub-content"
|
data-slot="menubar-sub-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -253,6 +254,7 @@ function MenubarSubContent({
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
</MenubarPortal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
73
lib/drive/docs-edit-actions.ts
Normal file
73
lib/drive/docs-edit-actions.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import type { Editor } from "@tiptap/react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
function focusEditorView(editor: Editor) {
|
||||||
|
editor.commands.focus()
|
||||||
|
editor.view.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function docsEditCanUndo(editor: Editor | null): boolean {
|
||||||
|
if (!editor) return false
|
||||||
|
return editor.can().chain().focus().undo().run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function docsEditCanRedo(editor: Editor | null): boolean {
|
||||||
|
if (!editor) return false
|
||||||
|
return editor.can().chain().focus().redo().run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function docsEditUndo(editor: Editor | null) {
|
||||||
|
if (!editor) return
|
||||||
|
editor.chain().focus().undo().run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function docsEditRedo(editor: Editor | null) {
|
||||||
|
if (!editor) return
|
||||||
|
editor.chain().focus().redo().run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function docsEditCut(editor: Editor | null) {
|
||||||
|
if (!editor) return
|
||||||
|
focusEditorView(editor)
|
||||||
|
document.execCommand("cut")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function docsEditCopy(editor: Editor | null) {
|
||||||
|
if (!editor) return
|
||||||
|
focusEditorView(editor)
|
||||||
|
document.execCommand("copy")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function docsEditPaste(editor: Editor | null) {
|
||||||
|
if (!editor) return
|
||||||
|
focusEditorView(editor)
|
||||||
|
document.execCommand("paste")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function docsEditPastePlain(editor: Editor | null) {
|
||||||
|
if (!editor) return
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText()
|
||||||
|
editor.chain().focus().deleteSelection().insertContent(text).run()
|
||||||
|
} catch {
|
||||||
|
toast.error("Impossible de coller le texte")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function docsEditSelectAll(editor: Editor | null) {
|
||||||
|
if (!editor) return
|
||||||
|
editor.chain().focus().selectAll().run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function docsEditDeleteSelection(editor: Editor | null) {
|
||||||
|
if (!editor) return
|
||||||
|
if (!editor.state.selection.empty) {
|
||||||
|
editor.chain().focus().deleteSelection().run()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
editor.chain().focus().deleteForward().run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function docsEditFindReplace(_editor: Editor | null) {
|
||||||
|
toast.info("Rechercher et remplacer — bientôt disponible")
|
||||||
|
}
|
||||||
52
lib/drive/docs-editor-shortcuts.ts
Normal file
52
lib/drive/docs-editor-shortcuts.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { Extension, type Editor } from "@tiptap/core"
|
||||||
|
import { Plugin, PluginKey } from "@tiptap/pm/state"
|
||||||
|
import {
|
||||||
|
docsEditFindReplace,
|
||||||
|
docsEditPastePlain,
|
||||||
|
} from "@/lib/drive/docs-edit-actions"
|
||||||
|
import type { DocsShortcutId } from "@/lib/drive/docs-keyboard-shortcuts-config"
|
||||||
|
import { useDocsKeyboardShortcutsStore } from "@/lib/stores/docs-keyboard-shortcuts-store"
|
||||||
|
|
||||||
|
const PLUGIN_KEY = new PluginKey("docsEditorShortcuts")
|
||||||
|
|
||||||
|
function runEditorShortcut(id: DocsShortcutId, editor: Editor): boolean {
|
||||||
|
switch (id) {
|
||||||
|
case "edit.redo":
|
||||||
|
return editor.commands.redo()
|
||||||
|
case "edit.pastePlain":
|
||||||
|
void docsEditPastePlain(editor)
|
||||||
|
return true
|
||||||
|
case "edit.findReplace":
|
||||||
|
docsEditFindReplace(editor)
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editor shortcuts driven by the centralized docs keyboard-shortcuts config/store. */
|
||||||
|
export const DocsEditorShortcuts = Extension.create({
|
||||||
|
name: "docsEditorShortcuts",
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
const editor = this.editor
|
||||||
|
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: PLUGIN_KEY,
|
||||||
|
props: {
|
||||||
|
handleKeyDown: (_view, event) => {
|
||||||
|
const id = useDocsKeyboardShortcutsStore.getState().matchEvent(
|
||||||
|
event,
|
||||||
|
(definition) =>
|
||||||
|
definition.scope === "editor" && definition.handler === "custom"
|
||||||
|
)
|
||||||
|
if (!id) return false
|
||||||
|
if (!runEditorShortcut(id, editor)) return false
|
||||||
|
event.preventDefault()
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
80
lib/drive/docs-file-menu-export.ts
Normal file
80
lib/drive/docs-file-menu-export.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import type { Editor } from "@tiptap/react"
|
||||||
|
import { exportTipTapToDocx, type TipTapJSON } from "@/lib/drive/richtext-import"
|
||||||
|
|
||||||
|
export type DocsDownloadFormat =
|
||||||
|
| "docx"
|
||||||
|
| "pdf"
|
||||||
|
| "odt"
|
||||||
|
| "txt"
|
||||||
|
| "rtf"
|
||||||
|
| "html"
|
||||||
|
| "html-zip"
|
||||||
|
| "epub"
|
||||||
|
| "md"
|
||||||
|
|
||||||
|
export const DOCS_DOWNLOAD_FORMATS: { id: DocsDownloadFormat; label: string }[] = [
|
||||||
|
{ id: "docx", label: "Microsoft Word (.docx)" },
|
||||||
|
{ id: "pdf", label: "Document PDF (.pdf)" },
|
||||||
|
{ id: "odt", label: "Format OpenDocument (.odt)" },
|
||||||
|
{ id: "txt", label: "Texte brut (.txt)" },
|
||||||
|
{ id: "rtf", label: "Format texte enrichi (.rtf)" },
|
||||||
|
{ id: "html-zip", label: "Page Web (.html, zippé)" },
|
||||||
|
{ id: "epub", label: "Publication EPUB (.epub)" },
|
||||||
|
{ id: "md", label: "Markdown (.md)" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function downloadBlob(blob: Blob, fileName: string) {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const anchor = document.createElement("a")
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = fileName
|
||||||
|
anchor.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseNameWithoutExt(fileName: string): string {
|
||||||
|
const slash = fileName.lastIndexOf("/")
|
||||||
|
const base = slash >= 0 ? fileName.slice(slash + 1) : fileName
|
||||||
|
const dot = base.lastIndexOf(".")
|
||||||
|
return dot > 0 ? base.slice(0, dot) : base
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportFileName(sourceName: string, ext: string): string {
|
||||||
|
return `${baseNameWithoutExt(sourceName)}.${ext}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportDocsContent(
|
||||||
|
format: DocsDownloadFormat,
|
||||||
|
editor: Editor | null,
|
||||||
|
sourceName: string
|
||||||
|
): Promise<"done" | "unsupported"> {
|
||||||
|
if (!editor) return "unsupported"
|
||||||
|
|
||||||
|
const content = editor.getJSON() as TipTapJSON
|
||||||
|
const base = baseNameWithoutExt(sourceName)
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case "docx": {
|
||||||
|
const blob = await exportTipTapToDocx(content)
|
||||||
|
downloadBlob(blob, exportFileName(sourceName, "docx"))
|
||||||
|
return "done"
|
||||||
|
}
|
||||||
|
case "txt": {
|
||||||
|
const text = editor.getText()
|
||||||
|
downloadBlob(new Blob([text], { type: "text/plain;charset=utf-8" }), `${base}.txt`)
|
||||||
|
return "done"
|
||||||
|
}
|
||||||
|
case "md": {
|
||||||
|
const text = editor.getText()
|
||||||
|
downloadBlob(new Blob([text], { type: "text/markdown;charset=utf-8" }), `${base}.md`)
|
||||||
|
return "done"
|
||||||
|
}
|
||||||
|
case "html": {
|
||||||
|
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${base}</title></head><body>${editor.getHTML()}</body></html>`
|
||||||
|
downloadBlob(new Blob([html], { type: "text/html;charset=utf-8" }), `${base}.html`)
|
||||||
|
return "done"
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "unsupported"
|
||||||
|
}
|
||||||
|
}
|
||||||
88
lib/drive/docs-keyboard-shortcut.ts
Normal file
88
lib/drive/docs-keyboard-shortcut.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import type { DocsShortcutBinding } from "@/lib/drive/docs-keyboard-shortcuts-config"
|
||||||
|
|
||||||
|
export type { DocsShortcutBinding }
|
||||||
|
|
||||||
|
export function isMacPlatform(): boolean {
|
||||||
|
if (typeof navigator === "undefined") return false
|
||||||
|
return (
|
||||||
|
/Mac|iPhone|iPad|iPod/.test(navigator.platform) ||
|
||||||
|
(navigator.userAgent.includes("Mac") && "ontouchend" in document)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function docsModKeyLabel(): string {
|
||||||
|
return isMacPlatform() ? "⌘" : "Ctrl"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDocsShortcutBinding(
|
||||||
|
binding: DocsShortcutBinding
|
||||||
|
): Required<Pick<DocsShortcutBinding, "key">> & {
|
||||||
|
mod: boolean
|
||||||
|
shift: boolean
|
||||||
|
alt: boolean
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
key: binding.key.length === 1 ? binding.key.toLowerCase() : binding.key,
|
||||||
|
mod: binding.mod ?? true,
|
||||||
|
shift: binding.shift ?? false,
|
||||||
|
alt: binding.alt ?? false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display label, e.g. ⌘Z or Ctrl+Shift+V */
|
||||||
|
export function formatDocsShortcutBinding(binding: DocsShortcutBinding): string {
|
||||||
|
const normalized = normalizeDocsShortcutBinding(binding)
|
||||||
|
const mod = docsModKeyLabel()
|
||||||
|
const isMac = isMacPlatform()
|
||||||
|
const keyLabel =
|
||||||
|
normalized.key.length === 1 ? normalized.key.toUpperCase() : normalized.key
|
||||||
|
|
||||||
|
if (isMac) {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (normalized.shift) parts.push("Maj")
|
||||||
|
if (normalized.alt) parts.push("⌥")
|
||||||
|
if (parts.length > 0) {
|
||||||
|
return `${mod}+${parts.join("+")}+${keyLabel}`
|
||||||
|
}
|
||||||
|
return normalized.mod ? `${mod}${keyLabel}` : keyLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: string[] = []
|
||||||
|
if (normalized.shift) parts.push("Shift")
|
||||||
|
if (normalized.alt) parts.push("Alt")
|
||||||
|
if (normalized.mod) parts.push(mod)
|
||||||
|
parts.push(keyLabel)
|
||||||
|
return parts.join("+")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesDocsShortcutBinding(
|
||||||
|
event: KeyboardEvent,
|
||||||
|
binding: DocsShortcutBinding
|
||||||
|
): boolean {
|
||||||
|
const normalized = normalizeDocsShortcutBinding(binding)
|
||||||
|
const modPressed = isMacPlatform() ? event.metaKey : event.ctrlKey
|
||||||
|
|
||||||
|
if (normalized.mod && !modPressed) return false
|
||||||
|
if (!normalized.mod && modPressed) return false
|
||||||
|
|
||||||
|
const eventKey =
|
||||||
|
event.key.length === 1 ? event.key.toLowerCase() : event.key
|
||||||
|
|
||||||
|
if (eventKey !== normalized.key) return false
|
||||||
|
if (normalized.shift !== event.shiftKey) return false
|
||||||
|
if (normalized.alt !== event.altKey) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TipTap key string, e.g. Mod-Shift-h */
|
||||||
|
export function docsShortcutBindingToTipTapKey(binding: DocsShortcutBinding): string {
|
||||||
|
const normalized = normalizeDocsShortcutBinding(binding)
|
||||||
|
const parts: string[] = []
|
||||||
|
if (normalized.mod) parts.push("Mod")
|
||||||
|
if (normalized.shift) parts.push("Shift")
|
||||||
|
if (normalized.alt) parts.push("Alt")
|
||||||
|
parts.push(
|
||||||
|
normalized.key.length === 1 ? normalized.key.toLowerCase() : normalized.key
|
||||||
|
)
|
||||||
|
return parts.join("-")
|
||||||
|
}
|
||||||
141
lib/drive/docs-keyboard-shortcuts-config.ts
Normal file
141
lib/drive/docs-keyboard-shortcuts-config.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
/** Stable ids for docs keyboard shortcuts (user-configurable later). */
|
||||||
|
export type DocsShortcutId =
|
||||||
|
| "file.open"
|
||||||
|
| "file.print"
|
||||||
|
| "edit.undo"
|
||||||
|
| "edit.redo"
|
||||||
|
| "edit.cut"
|
||||||
|
| "edit.copy"
|
||||||
|
| "edit.paste"
|
||||||
|
| "edit.pastePlain"
|
||||||
|
| "edit.selectAll"
|
||||||
|
| "edit.findReplace"
|
||||||
|
|
||||||
|
export type DocsShortcutCategory = "file" | "edit"
|
||||||
|
|
||||||
|
/** Where the shortcut is handled. */
|
||||||
|
export type DocsShortcutScope = "document" | "editor"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the shortcut is executed.
|
||||||
|
* - tiptap/browser: native handling when the editor is focused (listed for menus + future settings)
|
||||||
|
* - custom: handled by our docs shortcut runtime
|
||||||
|
*/
|
||||||
|
export type DocsShortcutHandler = "tiptap" | "browser" | "custom"
|
||||||
|
|
||||||
|
export type DocsShortcutBinding = {
|
||||||
|
key: string
|
||||||
|
/** Cmd on macOS, Ctrl on Windows/Linux. Default true. */
|
||||||
|
mod?: boolean
|
||||||
|
shift?: boolean
|
||||||
|
alt?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocsShortcutDefinition = {
|
||||||
|
id: DocsShortcutId
|
||||||
|
/** Settings UI label (future). */
|
||||||
|
label: string
|
||||||
|
category: DocsShortcutCategory
|
||||||
|
scope: DocsShortcutScope
|
||||||
|
handler: DocsShortcutHandler
|
||||||
|
defaultBinding: DocsShortcutBinding
|
||||||
|
altBindings?: DocsShortcutBinding[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOCS_KEYBOARD_SHORTCUT_DEFINITIONS = [
|
||||||
|
{
|
||||||
|
id: "file.open",
|
||||||
|
label: "Ouvrir",
|
||||||
|
category: "file",
|
||||||
|
scope: "document",
|
||||||
|
handler: "custom",
|
||||||
|
defaultBinding: { key: "o", mod: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "file.print",
|
||||||
|
label: "Imprimer",
|
||||||
|
category: "file",
|
||||||
|
scope: "document",
|
||||||
|
handler: "custom",
|
||||||
|
defaultBinding: { key: "p", mod: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "edit.undo",
|
||||||
|
label: "Annuler",
|
||||||
|
category: "edit",
|
||||||
|
scope: "editor",
|
||||||
|
handler: "tiptap",
|
||||||
|
defaultBinding: { key: "z", mod: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "edit.redo",
|
||||||
|
label: "Rétablir",
|
||||||
|
category: "edit",
|
||||||
|
scope: "editor",
|
||||||
|
handler: "custom",
|
||||||
|
defaultBinding: { key: "y", mod: true },
|
||||||
|
altBindings: [{ key: "z", mod: true, shift: true }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "edit.cut",
|
||||||
|
label: "Couper",
|
||||||
|
category: "edit",
|
||||||
|
scope: "editor",
|
||||||
|
handler: "browser",
|
||||||
|
defaultBinding: { key: "x", mod: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "edit.copy",
|
||||||
|
label: "Copier",
|
||||||
|
category: "edit",
|
||||||
|
scope: "editor",
|
||||||
|
handler: "browser",
|
||||||
|
defaultBinding: { key: "c", mod: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "edit.paste",
|
||||||
|
label: "Coller",
|
||||||
|
category: "edit",
|
||||||
|
scope: "editor",
|
||||||
|
handler: "browser",
|
||||||
|
defaultBinding: { key: "v", mod: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "edit.pastePlain",
|
||||||
|
label: "Coller sans la mise en forme",
|
||||||
|
category: "edit",
|
||||||
|
scope: "editor",
|
||||||
|
handler: "custom",
|
||||||
|
defaultBinding: { key: "v", mod: true, shift: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "edit.selectAll",
|
||||||
|
label: "Tout sélectionner",
|
||||||
|
category: "edit",
|
||||||
|
scope: "editor",
|
||||||
|
handler: "tiptap",
|
||||||
|
defaultBinding: { key: "a", mod: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "edit.findReplace",
|
||||||
|
label: "Rechercher et remplacer",
|
||||||
|
category: "edit",
|
||||||
|
scope: "editor",
|
||||||
|
handler: "custom",
|
||||||
|
defaultBinding: { key: "h", mod: true, shift: true },
|
||||||
|
},
|
||||||
|
] as const satisfies readonly DocsShortcutDefinition[]
|
||||||
|
|
||||||
|
export const DOCS_KEYBOARD_SHORTCUTS_BY_ID: Record<
|
||||||
|
DocsShortcutId,
|
||||||
|
DocsShortcutDefinition
|
||||||
|
> = Object.fromEntries(
|
||||||
|
DOCS_KEYBOARD_SHORTCUT_DEFINITIONS.map((definition) => [definition.id, definition])
|
||||||
|
) as Record<DocsShortcutId, DocsShortcutDefinition>
|
||||||
|
|
||||||
|
/** User overrides persisted locally (future settings UI). */
|
||||||
|
export type DocsKeyboardShortcutsUserConfig = Partial<
|
||||||
|
Record<DocsShortcutId, DocsShortcutBinding>
|
||||||
|
>
|
||||||
|
|
||||||
|
export const DOCS_KEYBOARD_SHORTCUTS_STORAGE_KEY = "ultidrive-docs-keyboard-shortcuts"
|
||||||
95
lib/drive/docs-keyboard-shortcuts-runtime.ts
Normal file
95
lib/drive/docs-keyboard-shortcuts-runtime.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
formatDocsShortcutBinding,
|
||||||
|
matchesDocsShortcutBinding,
|
||||||
|
normalizeDocsShortcutBinding,
|
||||||
|
} from "@/lib/drive/docs-keyboard-shortcut"
|
||||||
|
import {
|
||||||
|
DOCS_KEYBOARD_SHORTCUT_DEFINITIONS,
|
||||||
|
DOCS_KEYBOARD_SHORTCUTS_BY_ID,
|
||||||
|
type DocsKeyboardShortcutsUserConfig,
|
||||||
|
type DocsShortcutBinding,
|
||||||
|
type DocsShortcutDefinition,
|
||||||
|
type DocsShortcutId,
|
||||||
|
} from "@/lib/drive/docs-keyboard-shortcuts-config"
|
||||||
|
|
||||||
|
export function getDocsShortcutDefinition(id: DocsShortcutId): DocsShortcutDefinition {
|
||||||
|
return DOCS_KEYBOARD_SHORTCUTS_BY_ID[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDocsShortcutBinding(
|
||||||
|
id: DocsShortcutId,
|
||||||
|
overrides: DocsKeyboardShortcutsUserConfig = {}
|
||||||
|
): DocsShortcutBinding {
|
||||||
|
return overrides[id] ?? DOCS_KEYBOARD_SHORTCUTS_BY_ID[id].defaultBinding
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDocsShortcutBindings(
|
||||||
|
id: DocsShortcutId,
|
||||||
|
overrides: DocsKeyboardShortcutsUserConfig = {}
|
||||||
|
): DocsShortcutBinding[] {
|
||||||
|
const definition = DOCS_KEYBOARD_SHORTCUTS_BY_ID[id]
|
||||||
|
const primary = resolveDocsShortcutBinding(id, overrides)
|
||||||
|
const alt = overrides[id] ? [] : (definition.altBindings ?? [])
|
||||||
|
return [primary, ...alt]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDocsShortcutId(
|
||||||
|
id: DocsShortcutId,
|
||||||
|
overrides: DocsKeyboardShortcutsUserConfig = {}
|
||||||
|
): string {
|
||||||
|
return formatDocsShortcutBinding(resolveDocsShortcutBinding(id, overrides))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchDocsShortcutEvent(
|
||||||
|
event: KeyboardEvent,
|
||||||
|
overrides: DocsKeyboardShortcutsUserConfig = {},
|
||||||
|
filter?: (definition: DocsShortcutDefinition) => boolean
|
||||||
|
): DocsShortcutId | null {
|
||||||
|
for (const definition of DOCS_KEYBOARD_SHORTCUT_DEFINITIONS) {
|
||||||
|
if (filter && !filter(definition)) continue
|
||||||
|
const bindings = resolveDocsShortcutBindings(definition.id, overrides)
|
||||||
|
if (bindings.some((binding) => matchesDocsShortcutBinding(event, binding))) {
|
||||||
|
return definition.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidDocsShortcutBinding(value: unknown): value is DocsShortcutBinding {
|
||||||
|
if (!value || typeof value !== "object") return false
|
||||||
|
const binding = value as DocsShortcutBinding
|
||||||
|
if (typeof binding.key !== "string" || binding.key.length === 0) return false
|
||||||
|
if (binding.mod != null && typeof binding.mod !== "boolean") return false
|
||||||
|
if (binding.shift != null && typeof binding.shift !== "boolean") return false
|
||||||
|
if (binding.alt != null && typeof binding.alt !== "boolean") return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeDocsKeyboardShortcutsUserConfig(
|
||||||
|
raw: unknown
|
||||||
|
): DocsKeyboardShortcutsUserConfig {
|
||||||
|
if (!raw || typeof raw !== "object") return {}
|
||||||
|
const input = raw as Record<string, unknown>
|
||||||
|
const output: DocsKeyboardShortcutsUserConfig = {}
|
||||||
|
|
||||||
|
for (const definition of DOCS_KEYBOARD_SHORTCUT_DEFINITIONS) {
|
||||||
|
const candidate = input[definition.id]
|
||||||
|
if (isValidDocsShortcutBinding(candidate)) {
|
||||||
|
output[definition.id] = normalizeDocsShortcutBinding(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Catalog for a future keyboard-shortcuts settings screen. */
|
||||||
|
export function listDocsKeyboardShortcutCatalog(
|
||||||
|
overrides: DocsKeyboardShortcutsUserConfig = {}
|
||||||
|
) {
|
||||||
|
return DOCS_KEYBOARD_SHORTCUT_DEFINITIONS.map((definition) => ({
|
||||||
|
...definition,
|
||||||
|
binding: resolveDocsShortcutBinding(definition.id, overrides),
|
||||||
|
display: formatDocsShortcutId(definition.id, overrides),
|
||||||
|
isCustomized: Boolean(overrides[definition.id]),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ import Collaboration from "@tiptap/extension-collaboration"
|
|||||||
import CollaborationCaret from "@tiptap/extension-collaboration-caret"
|
import CollaborationCaret from "@tiptap/extension-collaboration-caret"
|
||||||
import type { HocuspocusProvider } from "@hocuspocus/provider"
|
import type { HocuspocusProvider } from "@hocuspocus/provider"
|
||||||
import type * as Y from "yjs"
|
import type * as Y from "yjs"
|
||||||
|
import { DocsEditorShortcuts } from "@/lib/drive/docs-editor-shortcuts"
|
||||||
|
|
||||||
export function buildRichTextExtensions(options?: {
|
export function buildRichTextExtensions(options?: {
|
||||||
collaboration?: { document: Y.Doc }
|
collaboration?: { document: Y.Doc }
|
||||||
@ -63,7 +64,8 @@ export function buildRichTextExtensions(options?: {
|
|||||||
Image.configure({ inline: true, allowBase64: true }),
|
Image.configure({ inline: true, allowBase64: true }),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: options?.placeholder ?? "Commencez à écrire…",
|
placeholder: options?.placeholder ?? "Commencez à écrire…",
|
||||||
})
|
}),
|
||||||
|
DocsEditorShortcuts
|
||||||
)
|
)
|
||||||
|
|
||||||
return extensions
|
return extensions
|
||||||
|
|||||||
69
lib/drive/use-docs-edit-menu.tsx
Normal file
69
lib/drive/use-docs-edit-menu.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import type { Editor } from "@tiptap/react"
|
||||||
|
import type { DocsEditMenuActions, DocsEditMenuState } from "@/components/drive/richtext/docs-edit-menu"
|
||||||
|
import {
|
||||||
|
docsEditCanRedo,
|
||||||
|
docsEditCanUndo,
|
||||||
|
docsEditCopy,
|
||||||
|
docsEditCut,
|
||||||
|
docsEditDeleteSelection,
|
||||||
|
docsEditFindReplace,
|
||||||
|
docsEditPaste,
|
||||||
|
docsEditPastePlain,
|
||||||
|
docsEditRedo,
|
||||||
|
docsEditSelectAll,
|
||||||
|
docsEditUndo,
|
||||||
|
} from "@/lib/drive/docs-edit-actions"
|
||||||
|
|
||||||
|
export function useDocsEditMenu({
|
||||||
|
editor,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
editor: Editor | null
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
const [revision, setRevision] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || disabled) return
|
||||||
|
|
||||||
|
const refresh = () => setRevision((value) => value + 1)
|
||||||
|
editor.on("transaction", refresh)
|
||||||
|
editor.on("selectionUpdate", refresh)
|
||||||
|
return () => {
|
||||||
|
editor.off("transaction", refresh)
|
||||||
|
editor.off("selectionUpdate", refresh)
|
||||||
|
}
|
||||||
|
}, [disabled, editor])
|
||||||
|
|
||||||
|
const state = useMemo<DocsEditMenuState>(
|
||||||
|
() => ({
|
||||||
|
canUndo: docsEditCanUndo(editor),
|
||||||
|
canRedo: docsEditCanRedo(editor),
|
||||||
|
}),
|
||||||
|
[editor, revision]
|
||||||
|
)
|
||||||
|
|
||||||
|
const actions = useMemo<DocsEditMenuActions>(
|
||||||
|
() => ({
|
||||||
|
onUndo: () => docsEditUndo(editor),
|
||||||
|
onRedo: () => docsEditRedo(editor),
|
||||||
|
onCut: () => docsEditCut(editor),
|
||||||
|
onCopy: () => docsEditCopy(editor),
|
||||||
|
onPaste: () => docsEditPaste(editor),
|
||||||
|
onPastePlain: () => void docsEditPastePlain(editor),
|
||||||
|
onSelectAll: () => docsEditSelectAll(editor),
|
||||||
|
onDelete: () => docsEditDeleteSelection(editor),
|
||||||
|
onFindReplace: () => docsEditFindReplace(editor),
|
||||||
|
}),
|
||||||
|
[editor]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
actions,
|
||||||
|
state,
|
||||||
|
disabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
275
lib/drive/use-docs-file-menu.tsx
Normal file
275
lib/drive/use-docs-file-menu.tsx
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import type { Editor } from "@tiptap/react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { DocsDetailsDialog } from "@/components/drive/richtext/docs-details-dialog"
|
||||||
|
import { DocsOpenDialog } from "@/components/drive/richtext/docs-open-dialog"
|
||||||
|
import { DocsPageSetupDialog } from "@/components/drive/richtext/docs-page-setup-dialog"
|
||||||
|
import type { DocsFileMenuActions } from "@/components/drive/richtext/docs-file-menu"
|
||||||
|
import { DriveMoveDialog } from "@/components/drive/drive-move-dialog"
|
||||||
|
import { DriveNameDialog } from "@/components/drive/drive-name-dialog"
|
||||||
|
import { apiClient } from "@/lib/api/client"
|
||||||
|
import { useDriveList, useDriveMutations } from "@/lib/api/hooks/use-drive-queries"
|
||||||
|
import type { DriveFileInfo } from "@/lib/api/types"
|
||||||
|
import { displayFileName } from "@/lib/drive/display-file-name"
|
||||||
|
import { nextUntitledName } from "@/lib/drive/drive-default-name"
|
||||||
|
import { buildMoveDestination, parentFolderPath } from "@/lib/drive/drive-move-items"
|
||||||
|
import { buildDriveDocsEditHref } from "@/lib/drive/drive-url"
|
||||||
|
import {
|
||||||
|
exportDocsContent,
|
||||||
|
type DocsDownloadFormat,
|
||||||
|
} from "@/lib/drive/docs-file-menu-export"
|
||||||
|
import { openRichTextDocument } from "@/lib/drive/open-rich-text-document"
|
||||||
|
import type { PageFormatId } from "@/lib/drive/page-formats"
|
||||||
|
import { useDocsKeyboardShortcutsStore } from "@/lib/stores/docs-keyboard-shortcuts-store"
|
||||||
|
|
||||||
|
function buildCopyFileName(originalName: string, siblingNames: string[]): string {
|
||||||
|
const name = displayFileName(originalName)
|
||||||
|
const dot = name.lastIndexOf(".")
|
||||||
|
const base = dot > 0 ? name.slice(0, dot) : name
|
||||||
|
const ext = dot > 0 ? name.slice(dot) : ""
|
||||||
|
const siblings = siblingNames.map(displayFileName)
|
||||||
|
|
||||||
|
let candidate = `Copie de ${base}${ext}`
|
||||||
|
if (!siblings.includes(candidate)) return candidate
|
||||||
|
|
||||||
|
let n = 2
|
||||||
|
while (siblings.includes(`Copie de ${base} (${n})${ext}`)) {
|
||||||
|
n += 1
|
||||||
|
}
|
||||||
|
return `Copie de ${base} (${n})${ext}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDocsFileMenu({
|
||||||
|
file,
|
||||||
|
editor,
|
||||||
|
pageFormatId,
|
||||||
|
onPageFormatChange,
|
||||||
|
onShareClick,
|
||||||
|
onRenameRequest,
|
||||||
|
onFileMoved,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
file?: DriveFileInfo
|
||||||
|
editor: Editor | null
|
||||||
|
pageFormatId: PageFormatId
|
||||||
|
onPageFormatChange: (id: PageFormatId) => void
|
||||||
|
onShareClick?: () => void
|
||||||
|
onRenameRequest?: () => void
|
||||||
|
onFileMoved?: (newPath: string) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const mutations = useDriveMutations()
|
||||||
|
const parentPath = file ? parentFolderPath(file.path) : "/"
|
||||||
|
const siblingList = useDriveList(parentPath, 1, "", false)
|
||||||
|
const siblingNames = useMemo(
|
||||||
|
() => (siblingList.data?.files ?? []).map((item) => item.name),
|
||||||
|
[siblingList.data?.files]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [openDialogOpen, setOpenDialogOpen] = useState(false)
|
||||||
|
const [moveDialogOpen, setMoveDialogOpen] = useState(false)
|
||||||
|
const [newDocDialogOpen, setNewDocDialogOpen] = useState(false)
|
||||||
|
const [pageSetupOpen, setPageSetupOpen] = useState(false)
|
||||||
|
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||||
|
const [newDocDefaultName, setNewDocDefaultName] = useState("")
|
||||||
|
|
||||||
|
const navigateToFile = useCallback(
|
||||||
|
async (path: string) => {
|
||||||
|
const info = await apiClient.get<DriveFileInfo>(
|
||||||
|
`/drive/files/info${path.startsWith("/") ? path : `/${path}`}`
|
||||||
|
)
|
||||||
|
if (!info.file_id) {
|
||||||
|
throw new Error("Identifiant du document introuvable")
|
||||||
|
}
|
||||||
|
router.push(buildDriveDocsEditHref(info.file_id))
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
)
|
||||||
|
|
||||||
|
const createDocument = useCallback(
|
||||||
|
async (rawName: string) => {
|
||||||
|
const name = rawName.trim().replace(/\//g, "")
|
||||||
|
if (!name) return
|
||||||
|
const fileName = name.endsWith(".docx") ? name : `${name}.docx`
|
||||||
|
try {
|
||||||
|
const { path } = await mutations.createFile.mutateAsync({
|
||||||
|
parent_path: parentPath,
|
||||||
|
name: fileName,
|
||||||
|
kind: "document",
|
||||||
|
})
|
||||||
|
toast.success("Document créé")
|
||||||
|
await navigateToFile(path)
|
||||||
|
} catch {
|
||||||
|
toast.error("Impossible de créer le document")
|
||||||
|
throw new Error("create failed")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mutations.createFile, navigateToFile, parentPath]
|
||||||
|
)
|
||||||
|
|
||||||
|
const makeCopy = useCallback(async () => {
|
||||||
|
if (!file) return
|
||||||
|
const copyName = buildCopyFileName(file.name, siblingNames)
|
||||||
|
const destination = buildMoveDestination(parentPath, copyName)
|
||||||
|
try {
|
||||||
|
await mutations.copy.mutateAsync({ source: file.path, destination })
|
||||||
|
toast.success("Copie créée")
|
||||||
|
await navigateToFile(destination)
|
||||||
|
} catch {
|
||||||
|
toast.error("Impossible de créer la copie")
|
||||||
|
}
|
||||||
|
}, [file.name, file.path, mutations.copy, navigateToFile, parentPath, siblingNames])
|
||||||
|
|
||||||
|
const moveToTrash = useCallback(async () => {
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
await mutations.deleteFile.mutateAsync(file.path)
|
||||||
|
toast.success("Document déplacé dans la corbeille")
|
||||||
|
router.push("/drive")
|
||||||
|
} catch {
|
||||||
|
toast.error("Impossible de déplacer dans la corbeille")
|
||||||
|
}
|
||||||
|
}, [file.path, mutations.deleteFile, router])
|
||||||
|
|
||||||
|
const downloadFormat = useCallback(
|
||||||
|
async (format: DocsDownloadFormat) => {
|
||||||
|
if (!file) return
|
||||||
|
if (format === "pdf") {
|
||||||
|
window.print()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === "html-zip" || format === "odt" || format === "rtf" || format === "epub") {
|
||||||
|
toast.info("Export bientôt disponible pour ce format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await exportDocsContent(format, editor, file.name)
|
||||||
|
if (result === "unsupported") {
|
||||||
|
toast.error("Export indisponible")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Échec de l'export")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editor, file.name]
|
||||||
|
)
|
||||||
|
|
||||||
|
const soon = useCallback((label: string) => {
|
||||||
|
toast.info(`${label} — bientôt disponible`)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const menuDisabled = disabled || !file
|
||||||
|
|
||||||
|
const actions = useMemo<DocsFileMenuActions>(
|
||||||
|
() => ({
|
||||||
|
onNewDocument: () => {
|
||||||
|
setNewDocDefaultName(nextUntitledName(siblingNames, "Document", ".docx"))
|
||||||
|
setNewDocDialogOpen(true)
|
||||||
|
},
|
||||||
|
onNewFromTemplate: () => soon("Galerie de modèles"),
|
||||||
|
onOpen: () => setOpenDialogOpen(true),
|
||||||
|
onMakeCopy: () => void makeCopy(),
|
||||||
|
onShareWithUsers: () => onShareClick?.(),
|
||||||
|
onPublishToWeb: () => onShareClick?.(),
|
||||||
|
onEmailFile: () => soon("Envoi par e-mail"),
|
||||||
|
onEmailCollaborators: () => soon("Envoi aux collaborateurs"),
|
||||||
|
onEmailDraft: () => soon("Brouillon d'e-mail"),
|
||||||
|
onDownload: (format) => void downloadFormat(format),
|
||||||
|
onRename: () => onRenameRequest?.(),
|
||||||
|
onMove: () => setMoveDialogOpen(true),
|
||||||
|
onAddShortcut: () => soon("Raccourci Drive"),
|
||||||
|
onMoveToTrash: () => void moveToTrash(),
|
||||||
|
onNameCurrentVersion: () => soon("Nommer la version actuelle"),
|
||||||
|
onShowVersionHistory: () => soon("Historique des versions"),
|
||||||
|
onToggleOffline: () => soon("Disponible hors connexion"),
|
||||||
|
onDetails: () => setDetailsOpen(true),
|
||||||
|
onSecurityLimits: () => soon("Limites de sécurité"),
|
||||||
|
onPageSetup: () => setPageSetupOpen(true),
|
||||||
|
onPrint: () => window.print(),
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
downloadFormat,
|
||||||
|
makeCopy,
|
||||||
|
moveToTrash,
|
||||||
|
onRenameRequest,
|
||||||
|
onShareClick,
|
||||||
|
siblingNames,
|
||||||
|
soon,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (menuDisabled) return
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const id = useDocsKeyboardShortcutsStore.getState().matchEvent(
|
||||||
|
event,
|
||||||
|
(definition) => definition.scope === "document" && definition.handler === "custom"
|
||||||
|
)
|
||||||
|
if (id === "file.open") {
|
||||||
|
event.preventDefault()
|
||||||
|
setOpenDialogOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (id === "file.print") {
|
||||||
|
event.preventDefault()
|
||||||
|
window.print()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKeyDown)
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown)
|
||||||
|
}, [menuDisabled])
|
||||||
|
|
||||||
|
const dialogs: ReactNode = file ? (
|
||||||
|
<>
|
||||||
|
<DocsOpenDialog
|
||||||
|
open={openDialogOpen}
|
||||||
|
onOpenChange={setOpenDialogOpen}
|
||||||
|
onOpenFile={(target) =>
|
||||||
|
openRichTextDocument(target, {
|
||||||
|
push: (href) => router.push(href),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DriveMoveDialog
|
||||||
|
open={moveDialogOpen}
|
||||||
|
onOpenChange={setMoveDialogOpen}
|
||||||
|
sources={[file]}
|
||||||
|
mode="move"
|
||||||
|
onMoved={(destinationFolder) => {
|
||||||
|
if (!destinationFolder) return
|
||||||
|
onFileMoved?.(buildMoveDestination(destinationFolder, file.name))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DriveNameDialog
|
||||||
|
open={newDocDialogOpen}
|
||||||
|
onOpenChange={setNewDocDialogOpen}
|
||||||
|
title="Nouveau document"
|
||||||
|
description="Nom du document à créer dans le dossier actuel."
|
||||||
|
defaultValue={newDocDefaultName}
|
||||||
|
confirmLabel="Créer"
|
||||||
|
onConfirm={createDocument}
|
||||||
|
/>
|
||||||
|
<DocsPageSetupDialog
|
||||||
|
open={pageSetupOpen}
|
||||||
|
onOpenChange={setPageSetupOpen}
|
||||||
|
pageFormatId={pageFormatId}
|
||||||
|
onPageFormatChange={onPageFormatChange}
|
||||||
|
/>
|
||||||
|
<DocsDetailsDialog open={detailsOpen} onOpenChange={setDetailsOpen} file={file} />
|
||||||
|
</>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
actions,
|
||||||
|
dialogs,
|
||||||
|
disabled: menuDisabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
35
lib/drive/use-docs-keyboard-shortcuts.ts
Normal file
35
lib/drive/use-docs-keyboard-shortcuts.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useDocsKeyboardShortcutsStore } from "@/lib/stores/docs-keyboard-shortcuts-store"
|
||||||
|
|
||||||
|
/** Read/update user docs keyboard shortcuts (central config + overrides). */
|
||||||
|
export function useDocsKeyboardShortcuts() {
|
||||||
|
const overrides = useDocsKeyboardShortcutsStore((state) => state.overrides)
|
||||||
|
const getBinding = useDocsKeyboardShortcutsStore((state) => state.getBinding)
|
||||||
|
const getBindings = useDocsKeyboardShortcutsStore((state) => state.getBindings)
|
||||||
|
const formatShortcut = useDocsKeyboardShortcutsStore((state) => state.formatShortcut)
|
||||||
|
const setBinding = useDocsKeyboardShortcutsStore((state) => state.setBinding)
|
||||||
|
const resetBinding = useDocsKeyboardShortcutsStore((state) => state.resetBinding)
|
||||||
|
const resetAll = useDocsKeyboardShortcutsStore((state) => state.resetAll)
|
||||||
|
const getCatalog = useDocsKeyboardShortcutsStore((state) => state.getCatalog)
|
||||||
|
|
||||||
|
return {
|
||||||
|
overrides,
|
||||||
|
getBinding,
|
||||||
|
getBindings,
|
||||||
|
formatShortcut,
|
||||||
|
setBinding,
|
||||||
|
resetBinding,
|
||||||
|
resetAll,
|
||||||
|
getCatalog,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DOCS_KEYBOARD_SHORTCUT_DEFINITIONS,
|
||||||
|
DOCS_KEYBOARD_SHORTCUTS_BY_ID,
|
||||||
|
type DocsKeyboardShortcutsUserConfig,
|
||||||
|
type DocsShortcutBinding,
|
||||||
|
type DocsShortcutDefinition,
|
||||||
|
type DocsShortcutId,
|
||||||
|
} from "@/lib/drive/docs-keyboard-shortcuts-config"
|
||||||
84
lib/stores/docs-keyboard-shortcuts-store.ts
Normal file
84
lib/stores/docs-keyboard-shortcuts-store.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { create } from "zustand"
|
||||||
|
import { persist } from "zustand/middleware"
|
||||||
|
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
|
||||||
|
import {
|
||||||
|
DOCS_KEYBOARD_SHORTCUTS_STORAGE_KEY,
|
||||||
|
type DocsKeyboardShortcutsUserConfig,
|
||||||
|
type DocsShortcutBinding,
|
||||||
|
type DocsShortcutId,
|
||||||
|
} from "@/lib/drive/docs-keyboard-shortcuts-config"
|
||||||
|
import {
|
||||||
|
formatDocsShortcutId,
|
||||||
|
listDocsKeyboardShortcutCatalog,
|
||||||
|
matchDocsShortcutEvent,
|
||||||
|
resolveDocsShortcutBinding,
|
||||||
|
resolveDocsShortcutBindings,
|
||||||
|
sanitizeDocsKeyboardShortcutsUserConfig,
|
||||||
|
} from "@/lib/drive/docs-keyboard-shortcuts-runtime"
|
||||||
|
|
||||||
|
type DocsKeyboardShortcutsState = {
|
||||||
|
overrides: DocsKeyboardShortcutsUserConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocsKeyboardShortcutsActions = {
|
||||||
|
getBinding: (id: DocsShortcutId) => DocsShortcutBinding
|
||||||
|
getBindings: (id: DocsShortcutId) => DocsShortcutBinding[]
|
||||||
|
formatShortcut: (id: DocsShortcutId) => string
|
||||||
|
matchEvent: (
|
||||||
|
event: KeyboardEvent,
|
||||||
|
filter?: Parameters<typeof matchDocsShortcutEvent>[2]
|
||||||
|
) => DocsShortcutId | null
|
||||||
|
setBinding: (id: DocsShortcutId, binding: DocsShortcutBinding) => void
|
||||||
|
resetBinding: (id: DocsShortcutId) => void
|
||||||
|
resetAll: () => void
|
||||||
|
getCatalog: () => ReturnType<typeof listDocsKeyboardShortcutCatalog>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDocsKeyboardShortcutsStore = create<
|
||||||
|
DocsKeyboardShortcutsState & DocsKeyboardShortcutsActions
|
||||||
|
>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
overrides: {},
|
||||||
|
|
||||||
|
getBinding: (id) => resolveDocsShortcutBinding(id, get().overrides),
|
||||||
|
|
||||||
|
getBindings: (id) => resolveDocsShortcutBindings(id, get().overrides),
|
||||||
|
|
||||||
|
formatShortcut: (id) => formatDocsShortcutId(id, get().overrides),
|
||||||
|
|
||||||
|
matchEvent: (event, filter) => matchDocsShortcutEvent(event, get().overrides, filter),
|
||||||
|
|
||||||
|
setBinding: (id, binding) => {
|
||||||
|
set((state) => ({
|
||||||
|
overrides: { ...state.overrides, [id]: binding },
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
resetBinding: (id) => {
|
||||||
|
set((state) => {
|
||||||
|
const next = { ...state.overrides }
|
||||||
|
delete next[id]
|
||||||
|
return { overrides: next }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
resetAll: () => set({ overrides: {} }),
|
||||||
|
|
||||||
|
getCatalog: () => listDocsKeyboardShortcutCatalog(get().overrides),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: DOCS_KEYBOARD_SHORTCUTS_STORAGE_KEY,
|
||||||
|
storage: debouncedPersistJSONStorage,
|
||||||
|
partialize: (state) => ({ overrides: state.overrides }),
|
||||||
|
merge: (persisted, current) => ({
|
||||||
|
...current,
|
||||||
|
overrides: sanitizeDocsKeyboardShortcutsUserConfig(
|
||||||
|
(persisted as DocsKeyboardShortcutsState | undefined)?.overrides
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
@ -39,7 +39,7 @@
|
|||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Google Docs–style chrome */
|
/* Google Docs–style menubar & menus */
|
||||||
.docs-menu-trigger {
|
.docs-menu-trigger {
|
||||||
height: auto;
|
height: auto;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
@ -52,18 +52,29 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.docs-menubar [data-slot="menubar-trigger"]:hover,
|
||||||
.docs-menu-trigger:hover,
|
.docs-menu-trigger:hover,
|
||||||
.docs-menu-trigger[data-state="open"] {
|
.docs-menubar [data-slot="menubar-trigger"][data-state="open"],
|
||||||
background: #e8eaed;
|
.docs-menu-trigger[data-state="open"],
|
||||||
|
.docs-menubar [data-slot="menubar-trigger"][data-highlighted],
|
||||||
|
.docs-menu-trigger[data-highlighted] {
|
||||||
|
background-color: #e8eaed !important;
|
||||||
|
color: #3c4043 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .docs-menu-trigger {
|
html.dark .docs-menubar [data-slot="menubar-trigger"],
|
||||||
color: hsl(var(--foreground));
|
html.dark .docs-menu-trigger {
|
||||||
|
color: #e8eaed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .docs-menu-trigger:hover,
|
html.dark .docs-menubar [data-slot="menubar-trigger"]:hover,
|
||||||
.dark .docs-menu-trigger[data-state="open"] {
|
html.dark .docs-menu-trigger:hover,
|
||||||
background: hsl(var(--muted));
|
html.dark .docs-menubar [data-slot="menubar-trigger"][data-state="open"],
|
||||||
|
html.dark .docs-menu-trigger[data-state="open"],
|
||||||
|
html.dark .docs-menubar [data-slot="menubar-trigger"][data-highlighted],
|
||||||
|
html.dark .docs-menu-trigger[data-highlighted] {
|
||||||
|
background-color: #3c4043 !important;
|
||||||
|
color: #e8eaed !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-toolbar-btn:hover:not(:disabled) {
|
.docs-toolbar-btn:hover:not(:disabled) {
|
||||||
@ -96,6 +107,72 @@
|
|||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.docs-menu-content {
|
||||||
|
min-width: 280px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-docs-menu-surface] {
|
||||||
|
background-color: #fff;
|
||||||
|
color: #3c4043;
|
||||||
|
border-color: #dadce0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-docs-menu-surface] [data-slot="menubar-item"]:focus,
|
||||||
|
[data-docs-menu-surface] [data-slot="menubar-item"][data-highlighted],
|
||||||
|
[data-docs-menu-surface] [data-slot="menubar-sub-trigger"]:focus,
|
||||||
|
[data-docs-menu-surface] [data-slot="menubar-sub-trigger"][data-highlighted],
|
||||||
|
[data-docs-menu-surface] [data-slot="menubar-sub-trigger"][data-state="open"],
|
||||||
|
[data-docs-menu-surface] .docs-menu-item:focus,
|
||||||
|
[data-docs-menu-surface] .docs-menu-item[data-highlighted],
|
||||||
|
[data-docs-menu-surface] .docs-menu-item[data-state="open"] {
|
||||||
|
background-color: #e8eaed !important;
|
||||||
|
color: #3c4043 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark [data-docs-menu-surface] {
|
||||||
|
background-color: #35363a !important;
|
||||||
|
color: #e8eaed !important;
|
||||||
|
border-color: #3c4043 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark [data-docs-menu-surface] [data-slot="menubar-item"]:focus,
|
||||||
|
html.dark [data-docs-menu-surface] [data-slot="menubar-item"][data-highlighted],
|
||||||
|
html.dark [data-docs-menu-surface] [data-slot="menubar-sub-trigger"]:focus,
|
||||||
|
html.dark [data-docs-menu-surface] [data-slot="menubar-sub-trigger"][data-highlighted],
|
||||||
|
html.dark [data-docs-menu-surface] [data-slot="menubar-sub-trigger"][data-state="open"],
|
||||||
|
html.dark [data-docs-menu-surface] .docs-menu-item:focus,
|
||||||
|
html.dark [data-docs-menu-surface] .docs-menu-item[data-highlighted],
|
||||||
|
html.dark [data-docs-menu-surface] .docs-menu-item[data-state="open"] {
|
||||||
|
background-color: #3c4043 !important;
|
||||||
|
color: #e8eaed !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark [data-docs-menu-surface] [data-slot="menubar-separator"] {
|
||||||
|
background-color: #3c4043 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-menu-item {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-menu-sub-content {
|
||||||
|
z-index: 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-menu-item-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 20px;
|
||||||
|
shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #3c4043;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .docs-menu-item-icon {
|
||||||
|
color: #e8eaed;
|
||||||
|
}
|
||||||
|
|
||||||
.docs-toolbar-shell {
|
.docs-toolbar-shell {
|
||||||
padding: 0 12px 8px;
|
padding: 0 12px 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user