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

This commit is contained in:
R3D347HR4Y 2026-06-09 18:27:10 +02:00
parent 5b1cc5e83c
commit 79bb6193fc
27 changed files with 2006 additions and 33 deletions

View File

@ -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(() => {

View File

@ -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}

View File

@ -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,
]
)

View File

@ -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}
</>
)
}

View 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>
)
}

View 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>
)
}

View 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&apos;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&apos;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&apos;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>
)
}

View 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>
}

View 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

View File

@ -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>

View 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>
)
}

View 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&apos;affichage et l&apos;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>
)
}

View File

@ -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(

View File

@ -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>
)
}

View 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")
}

View 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
},
},
}),
]
},
})

View 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"
}
}

View 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("-")
}

View 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"

View 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]),
}))
}

View File

@ -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

View 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,
}
}

View 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,
}
}

View 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"

View 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
),
}),
}
)
)

View File

@ -39,7 +39,7 @@
vertical-align: top;
}
/* Google Docsstyle chrome */
/* Google Docsstyle 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