This commit is contained in:
parent
5b1cc5e83c
commit
79bb6193fc
@ -11,11 +11,14 @@ export function OfficeEditorInlineTitle({
|
||||
onRename,
|
||||
disabled = false,
|
||||
className,
|
||||
renameSignal,
|
||||
}: {
|
||||
value: string
|
||||
onRename: (next: string) => Promise<void>
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
/** Increment to start inline rename from an external action (e.g. menu Fichier). */
|
||||
renameSignal?: number
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(value)
|
||||
@ -27,6 +30,11 @@ export function OfficeEditorInlineTitle({
|
||||
if (!editing) setDraft(value)
|
||||
}, [value, editing])
|
||||
|
||||
useEffect(() => {
|
||||
if (!renameSignal || disabled) return
|
||||
setEditing(true)
|
||||
}, [renameSignal, disabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) return
|
||||
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 type { RichTextSaveStatus, RichTextSessionResponse } from "@/lib/drive/richtext-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 { useCollabPresence } from "@/lib/drive/use-collab-presence"
|
||||
import { apiClient } from "@/lib/api/client"
|
||||
@ -35,6 +37,9 @@ export type RichTextDocsChromeProps = {
|
||||
trailing?: ReactNode
|
||||
moveFile?: DriveFileInfo
|
||||
onFileMoved?: (newPath: string) => void
|
||||
file?: DriveFileInfo
|
||||
onRenameRequest?: () => void
|
||||
renameSignal?: number
|
||||
}
|
||||
|
||||
export function RichTextDocumentEditor({
|
||||
@ -255,6 +260,34 @@ export function RichTextDocumentEditor({
|
||||
}
|
||||
}, [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(() => {
|
||||
if (!editor || collaboration || !importDone || session.importRequired) return
|
||||
let cancelled = false
|
||||
@ -296,9 +329,9 @@ export function RichTextDocumentEditor({
|
||||
: "Connexion…"
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{chrome && !settings.chromeCollapsed ? (
|
||||
{chromeProps && !settings.chromeCollapsed ? (
|
||||
<DocsChrome
|
||||
{...chrome}
|
||||
{...chromeProps}
|
||||
saveStatus={saveStatus}
|
||||
presenceUsers={presenceUsers}
|
||||
pageFormatId={settings.pageFormatId}
|
||||
@ -316,9 +349,9 @@ export function RichTextDocumentEditor({
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-background">
|
||||
{chrome && !settings.chromeCollapsed ? (
|
||||
{chromeProps && !settings.chromeCollapsed ? (
|
||||
<DocsChrome
|
||||
{...chrome}
|
||||
{...chromeProps}
|
||||
saveStatus={saveStatus}
|
||||
presenceUsers={presenceUsers}
|
||||
pageFormatId={settings.pageFormatId}
|
||||
|
||||
@ -38,6 +38,7 @@ export function RichTextEditor({ fileId }: { fileId: string }) {
|
||||
const displayPath = file?.path ?? ""
|
||||
const [session, setSession] = useState<RichTextSessionResponse | null>(null)
|
||||
const [sessionError, setSessionError] = useState<string | null>(null)
|
||||
const [renameSignal, setRenameSignal] = useState(0)
|
||||
|
||||
const fileName = file?.name ?? fileNameFromPath(displayPath)
|
||||
const title = displayFileBaseName(fileName)
|
||||
@ -134,6 +135,9 @@ export function RichTextEditor({ fileId }: { fileId: string }) {
|
||||
showAccount: true,
|
||||
moveFile,
|
||||
onFileMoved: handleFileMoved,
|
||||
file: moveFile,
|
||||
onRenameRequest: () => setRenameSignal((value) => value + 1),
|
||||
renameSignal,
|
||||
}),
|
||||
[
|
||||
title,
|
||||
@ -144,6 +148,7 @@ export function RichTextEditor({ fileId }: { fileId: string }) {
|
||||
openShare,
|
||||
moveFile,
|
||||
handleFileMoved,
|
||||
renameSignal,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@ -9,6 +9,8 @@ import { ShareDialog } from "@/components/drive/share-dialog"
|
||||
import { CollabPresenceAvatars } from "@/components/drive/richtext/collab-presence-avatars"
|
||||
import { DocsLogoIcon } from "@/components/drive/richtext/docs-logo-icon"
|
||||
import { DocsMenubar } from "@/components/drive/richtext/docs-menubar"
|
||||
import 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 { Button } from "@/components/ui/button"
|
||||
import type { DriveShare, DriveFileInfo } from "@/lib/api/types"
|
||||
@ -60,6 +62,13 @@ export function DocsChrome({
|
||||
trailing,
|
||||
moveFile,
|
||||
onFileMoved,
|
||||
fileMenuActions,
|
||||
fileMenuDialogs,
|
||||
fileMenuDisabled,
|
||||
editMenuActions,
|
||||
editMenuState,
|
||||
editMenuDisabled,
|
||||
renameSignal,
|
||||
}: {
|
||||
title: string
|
||||
onRename?: (next: string) => Promise<void>
|
||||
@ -81,6 +90,13 @@ export function DocsChrome({
|
||||
/** Propriétaire uniquement — affiche le bouton déplacer. */
|
||||
moveFile?: DriveFileInfo
|
||||
onFileMoved?: (newPath: string) => void
|
||||
fileMenuActions?: DocsFileMenuActions
|
||||
fileMenuDialogs?: ReactNode
|
||||
fileMenuDisabled?: boolean
|
||||
editMenuActions?: DocsEditMenuActions
|
||||
editMenuState?: DocsEditMenuState
|
||||
editMenuDisabled?: boolean
|
||||
renameSignal?: number
|
||||
}) {
|
||||
const shareIcon = resolveShareButtonIcon(shares)
|
||||
const statusText = saveStatusLabel(saveStatus)
|
||||
@ -112,6 +128,7 @@ export function DocsChrome({
|
||||
value={title}
|
||||
onRename={onRename}
|
||||
disabled={renameDisabled}
|
||||
renameSignal={renameSignal}
|
||||
className="pr-1 text-base font-normal"
|
||||
/>
|
||||
) : (
|
||||
@ -144,13 +161,18 @@ export function DocsChrome({
|
||||
) : null}
|
||||
</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
|
||||
className="docs-menubar shrink-0"
|
||||
pageFormatId={pageFormatId}
|
||||
onPageFormatChange={onPageFormatChange}
|
||||
zoom={zoom}
|
||||
onZoomChange={onZoomChange}
|
||||
fileMenuActions={fileMenuActions}
|
||||
fileMenuDisabled={fileMenuDisabled}
|
||||
editMenuActions={editMenuActions}
|
||||
editMenuState={editMenuState}
|
||||
editMenuDisabled={editMenuDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -180,6 +202,7 @@ export function DocsChrome({
|
||||
</div>
|
||||
</header>
|
||||
{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"
|
||||
|
||||
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 {
|
||||
Menubar,
|
||||
MenubarContent,
|
||||
@ -11,9 +14,7 @@ import {
|
||||
import { PAGE_FORMATS, type PageFormatId } from "@/lib/drive/page-formats"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const MENU_LABELS = [
|
||||
"Fichier",
|
||||
"Édition",
|
||||
const OTHER_MENU_LABELS = [
|
||||
"Affichage",
|
||||
"Insertion",
|
||||
"Format",
|
||||
@ -26,12 +27,22 @@ export function DocsMenubar({
|
||||
onPageFormatChange,
|
||||
zoom,
|
||||
onZoomChange,
|
||||
fileMenuActions,
|
||||
fileMenuDisabled,
|
||||
editMenuActions,
|
||||
editMenuState,
|
||||
editMenuDisabled,
|
||||
className,
|
||||
}: {
|
||||
pageFormatId: PageFormatId
|
||||
onPageFormatChange: (id: PageFormatId) => void
|
||||
zoom: number
|
||||
onZoomChange: (zoom: number) => void
|
||||
fileMenuActions?: DocsFileMenuActions
|
||||
fileMenuDisabled?: boolean
|
||||
editMenuActions?: DocsEditMenuActions
|
||||
editMenuState?: DocsEditMenuState
|
||||
editMenuDisabled?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
@ -41,12 +52,46 @@ export function DocsMenubar({
|
||||
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") {
|
||||
return (
|
||||
<MenubarMenu key={label}>
|
||||
<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">
|
||||
Mode (bientôt)
|
||||
</MenubarItem>
|
||||
@ -87,7 +132,11 @@ export function DocsMenubar({
|
||||
return (
|
||||
<MenubarMenu key={label}>
|
||||
<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">
|
||||
Bientôt disponible
|
||||
</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 [linkOpen, setLinkOpen] = useState(false)
|
||||
const [linkUrl, setLinkUrl] = useState("")
|
||||
const [lastHighlightColor, setLastHighlightColor] = useState<string>(HIGHLIGHT_COLORS[0]!)
|
||||
const toolbarState = useDocsToolbarState(editor)
|
||||
|
||||
const insertImage = useCallback(
|
||||
@ -421,10 +420,9 @@ function DocsToolbarInner({
|
||||
<HighlightColorPicker
|
||||
disabled={disabled}
|
||||
colors={HIGHLIGHT_COLORS}
|
||||
currentColor={highlightColor ?? lastHighlightColor}
|
||||
currentColor={highlightColor ?? "transparent"}
|
||||
isActive={highlightColor != null}
|
||||
onPick={(color) => {
|
||||
setLastHighlightColor(color)
|
||||
editor.chain().focus().setHighlight({ color }).run()
|
||||
}}
|
||||
onClear={() => editor.chain().focus().unsetHighlight().run()}
|
||||
@ -602,7 +600,6 @@ function DocsToolbarInner({
|
||||
linkOpen,
|
||||
linkUrl,
|
||||
applyLink,
|
||||
lastHighlightColor,
|
||||
])
|
||||
|
||||
const { containerRef, measureRef, visibleCount, hasOverflow } = useToolbarOverflow(
|
||||
|
||||
@ -245,14 +245,16 @@ function MenubarSubContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
|
||||
return (
|
||||
<MenubarPrimitive.SubContent
|
||||
data-slot="menubar-sub-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<MenubarPortal>
|
||||
<MenubarPrimitive.SubContent
|
||||
data-slot="menubar-sub-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
{...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 type { HocuspocusProvider } from "@hocuspocus/provider"
|
||||
import type * as Y from "yjs"
|
||||
import { DocsEditorShortcuts } from "@/lib/drive/docs-editor-shortcuts"
|
||||
|
||||
export function buildRichTextExtensions(options?: {
|
||||
collaboration?: { document: Y.Doc }
|
||||
@ -63,7 +64,8 @@ export function buildRichTextExtensions(options?: {
|
||||
Image.configure({ inline: true, allowBase64: true }),
|
||||
Placeholder.configure({
|
||||
placeholder: options?.placeholder ?? "Commencez à écrire…",
|
||||
})
|
||||
}),
|
||||
DocsEditorShortcuts
|
||||
)
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* Google Docs–style chrome */
|
||||
/* Google Docs–style menubar & menus */
|
||||
.docs-menu-trigger {
|
||||
height: auto;
|
||||
padding: 2px 8px;
|
||||
@ -52,18 +52,29 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.docs-menubar [data-slot="menubar-trigger"]:hover,
|
||||
.docs-menu-trigger:hover,
|
||||
.docs-menu-trigger[data-state="open"] {
|
||||
background: #e8eaed;
|
||||
.docs-menubar [data-slot="menubar-trigger"][data-state="open"],
|
||||
.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 {
|
||||
color: hsl(var(--foreground));
|
||||
html.dark .docs-menubar [data-slot="menubar-trigger"],
|
||||
html.dark .docs-menu-trigger {
|
||||
color: #e8eaed;
|
||||
}
|
||||
|
||||
.dark .docs-menu-trigger:hover,
|
||||
.dark .docs-menu-trigger[data-state="open"] {
|
||||
background: hsl(var(--muted));
|
||||
html.dark .docs-menubar [data-slot="menubar-trigger"]:hover,
|
||||
html.dark .docs-menu-trigger:hover,
|
||||
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) {
|
||||
@ -96,6 +107,72 @@
|
||||
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 {
|
||||
padding: 0 12px 8px;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user