308 lines
9.6 KiB
TypeScript
308 lines
9.6 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
import type { Editor } from "@tiptap/react"
|
|
import { Link2, Loader2, Search } from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
|
import type { DriveFileInfo } from "@/lib/api/types"
|
|
import { useDriveSearchSuggestions } from "@/lib/api/hooks/use-drive-queries"
|
|
import { DriveFileTypeIcon } from "@/lib/drive/drive-file-icon"
|
|
import { itemLocationLabel } from "@/lib/drive/drive-search"
|
|
import { DOCS_LINK_POPOVER_EVENT } from "@/lib/drive/docs-link-bridge"
|
|
import { normalizeDocsLinkHref, resolveDriveItemLinkHref } from "@/lib/drive/docs-link-href"
|
|
import { displayFileName } from "@/lib/drive/display-file-name"
|
|
import { useDebouncedValue } from "@/lib/hooks/use-debounced-value"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
type SavedSelection = { from: number; to: number }
|
|
|
|
function DocsLinkToolbarBtn({
|
|
active,
|
|
disabled,
|
|
onPrepareOpen,
|
|
}: {
|
|
active?: boolean
|
|
disabled?: boolean
|
|
onPrepareOpen: () => void
|
|
}) {
|
|
return (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn(
|
|
"docs-toolbar-btn size-7 shrink-0",
|
|
active && "docs-toolbar-btn--active"
|
|
)}
|
|
disabled={disabled}
|
|
aria-label="Hyperlien"
|
|
title="Hyperlien"
|
|
aria-pressed={active}
|
|
onMouseDown={(event) => {
|
|
event.preventDefault()
|
|
onPrepareOpen()
|
|
}}
|
|
>
|
|
<Link2 className="size-4" />
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
function DriveFileSuggestionRow({
|
|
item,
|
|
disabled,
|
|
onPick,
|
|
}: {
|
|
item: DriveFileInfo
|
|
disabled?: boolean
|
|
onPick: (item: DriveFileInfo) => void
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
className="flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent disabled:pointer-events-none disabled:opacity-50"
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
onClick={() => onPick(item)}
|
|
>
|
|
<DriveFileTypeIcon file={item} className="size-4 shrink-0" />
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm">{displayFileName(item.name)}</span>
|
|
<span className="block truncate text-xs text-muted-foreground">
|
|
{itemLocationLabel(item.path, item.type)}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
export function DocsLinkPopover({
|
|
editor,
|
|
disabled,
|
|
active,
|
|
}: {
|
|
editor: Editor
|
|
disabled?: boolean
|
|
active?: boolean
|
|
}) {
|
|
const [open, setOpen] = useState(false)
|
|
const [linkUrl, setLinkUrl] = useState("")
|
|
const [driveQuery, setDriveQuery] = useState("")
|
|
const [resolvingDriveFile, setResolvingDriveFile] = useState(false)
|
|
const urlInputRef = useRef<HTMLInputElement>(null)
|
|
const selectionRef = useRef<SavedSelection | null>(null)
|
|
const debouncedDriveQuery = useDebouncedValue(driveQuery, 250)
|
|
|
|
const { data: driveResults, isFetching: driveLoading } = useDriveSearchSuggestions(
|
|
debouncedDriveQuery,
|
|
"all",
|
|
"/",
|
|
open
|
|
)
|
|
const driveSuggestions = driveResults?.files ?? []
|
|
|
|
const hasTextSelection = useCallback(() => {
|
|
const sel = selectionRef.current
|
|
return Boolean(sel && sel.from !== sel.to)
|
|
}, [])
|
|
|
|
const applyLink = useCallback(
|
|
(rawUrl?: string) => {
|
|
const url = normalizeDocsLinkHref(rawUrl ?? linkUrl)
|
|
const sel = selectionRef.current
|
|
|
|
let chain = editor.chain().focus()
|
|
if (sel && sel.from !== sel.to) {
|
|
chain = chain.setTextSelection({ from: sel.from, to: sel.to })
|
|
} else if (editor.isActive("link")) {
|
|
chain = chain.extendMarkRange("link")
|
|
} else {
|
|
return
|
|
}
|
|
|
|
if (!url) {
|
|
chain.unsetLink().run()
|
|
} else {
|
|
chain.setLink({ href: url }).run()
|
|
}
|
|
|
|
setOpen(false)
|
|
setLinkUrl("")
|
|
setDriveQuery("")
|
|
selectionRef.current = null
|
|
},
|
|
[editor, linkUrl]
|
|
)
|
|
|
|
const prepareOpen = useCallback(() => {
|
|
const { from, to } = editor.state.selection
|
|
selectionRef.current = { from, to }
|
|
const prev = editor.getAttributes("link").href as string | undefined
|
|
setLinkUrl(prev ?? "")
|
|
setDriveQuery("")
|
|
}, [editor])
|
|
|
|
const pickDriveFile = useCallback(
|
|
async (file: DriveFileInfo) => {
|
|
setResolvingDriveFile(true)
|
|
try {
|
|
const href = await resolveDriveItemLinkHref(file)
|
|
setLinkUrl(href)
|
|
applyLink(href)
|
|
} catch {
|
|
window.alert("Impossible de créer le lien vers ce fichier.")
|
|
} finally {
|
|
setResolvingDriveFile(false)
|
|
}
|
|
},
|
|
[applyLink]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const id = window.requestAnimationFrame(() => {
|
|
urlInputRef.current?.focus()
|
|
urlInputRef.current?.select()
|
|
})
|
|
return () => window.cancelAnimationFrame(id)
|
|
}, [open])
|
|
|
|
useEffect(() => {
|
|
const onOpenRequest = () => {
|
|
prepareOpen()
|
|
setOpen(true)
|
|
}
|
|
window.addEventListener(DOCS_LINK_POPOVER_EVENT, onOpenRequest)
|
|
return () => window.removeEventListener(DOCS_LINK_POPOVER_EVENT, onOpenRequest)
|
|
}, [prepareOpen])
|
|
|
|
const canApply = hasTextSelection() || editor.isActive("link")
|
|
const trimmedDriveQuery = driveQuery.trim()
|
|
|
|
return (
|
|
<Popover
|
|
open={open}
|
|
onOpenChange={(next) => {
|
|
setOpen(next)
|
|
if (!next) {
|
|
setLinkUrl("")
|
|
setDriveQuery("")
|
|
selectionRef.current = null
|
|
}
|
|
}}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<DocsLinkToolbarBtn
|
|
active={active}
|
|
disabled={disabled}
|
|
onPrepareOpen={prepareOpen}
|
|
/>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-80 p-0" align="start" sideOffset={6}>
|
|
<div className="border-b px-3 py-2">
|
|
<label htmlFor="docs-link-url" className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
|
Adresse du lien
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
ref={urlInputRef}
|
|
id="docs-link-url"
|
|
type="text"
|
|
inputMode="url"
|
|
value={linkUrl}
|
|
placeholder="https:// ou /drive/…"
|
|
disabled={disabled || resolvingDriveFile}
|
|
className="h-8 min-w-0 flex-1 rounded-md border border-input bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
onChange={(event) => setLinkUrl(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault()
|
|
applyLink()
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
disabled={disabled || !canApply || resolvingDriveFile}
|
|
onClick={() => applyLink()}
|
|
>
|
|
OK
|
|
</Button>
|
|
</div>
|
|
{!canApply ? (
|
|
<p className="mt-1.5 text-xs text-muted-foreground">
|
|
Sélectionnez du texte pour créer un hyperlien.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="px-3 py-2">
|
|
<label htmlFor="docs-link-drive-search" className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
|
Fichier du Drive
|
|
</label>
|
|
<div className="relative">
|
|
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
<input
|
|
id="docs-link-drive-search"
|
|
type="search"
|
|
value={driveQuery}
|
|
placeholder="Rechercher un fichier…"
|
|
disabled={disabled || resolvingDriveFile}
|
|
className="h-8 w-full rounded-md border border-input bg-background py-0 pl-7 pr-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
onChange={(event) => setDriveQuery(event.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="max-h-48 overflow-y-auto border-t px-1 py-1">
|
|
{resolvingDriveFile ? (
|
|
<div className="flex items-center gap-2 px-2 py-3 text-sm text-muted-foreground">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
Création du lien…
|
|
</div>
|
|
) : trimmedDriveQuery.length < 2 ? (
|
|
<p className="px-2 py-3 text-xs text-muted-foreground">
|
|
Saisissez au moins 2 caractères pour rechercher dans votre Drive.
|
|
</p>
|
|
) : driveLoading ? (
|
|
<div className="flex items-center gap-2 px-2 py-3 text-sm text-muted-foreground">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
Recherche…
|
|
</div>
|
|
) : driveSuggestions.length === 0 ? (
|
|
<p className="px-2 py-3 text-sm text-muted-foreground">
|
|
Aucun fichier pour « {trimmedDriveQuery} »
|
|
</p>
|
|
) : (
|
|
driveSuggestions.map((item) => (
|
|
<DriveFileSuggestionRow
|
|
key={item.path}
|
|
item={item}
|
|
disabled={disabled || !canApply}
|
|
onPick={pickDriveFile}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{editor.isActive("link") ? (
|
|
<div className="border-t px-2 py-1.5">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-full justify-start text-destructive hover:text-destructive"
|
|
disabled={disabled}
|
|
onClick={() => applyLink("")}
|
|
>
|
|
Supprimer le lien
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|