From 2a7c1537482a9b6b67d9cb3b5f0bfdf7e34b0995 Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Wed, 10 Jun 2026 12:48:27 +0200 Subject: [PATCH] wrap page --- app/globals.css | 25 +- components/drive/richtext-document.tsx | 352 +++++-- .../drive/richtext/docs-body-margin-masks.tsx | 65 ++ components/drive/richtext/docs-chrome.tsx | 23 +- .../drive/richtext/docs-editor-workspace.tsx | 170 ++++ .../richtext/docs-exclusive-menu-sub.tsx | 59 ++ components/drive/richtext/docs-file-menu.tsx | 27 +- .../richtext/docs-graphic-context-menu.tsx | 136 +++ .../richtext/docs-graphic-crop-overlay.tsx | 143 +++ .../drive/richtext/docs-graphic-node-view.tsx | 470 +++++++++ .../richtext/docs-graphic-options-panel.tsx | 257 +++++ .../richtext/docs-graphic-toolbar-menu.tsx | 222 +++++ .../richtext/docs-header-footer-dialogs.tsx | 208 ++++ .../richtext/docs-header-footer-region.tsx | 481 ++++++++++ .../drive/richtext/docs-horizontal-ruler.tsx | 139 +++ components/drive/richtext/docs-menubar.tsx | 92 +- components/drive/richtext/docs-page-view.tsx | 459 +++++++-- .../drive/richtext/docs-region-editor.tsx | 135 +++ .../drive/richtext/docs-ruler-markers.tsx | 216 +++++ .../drive/richtext/docs-rulers-chrome.tsx | 137 +++ components/drive/richtext/docs-toolbar.tsx | 78 +- .../drive/richtext/docs-vertical-ruler.tsx | 130 +++ components/drive/richtext/docs-view-menu.tsx | 291 ++++++ .../richtext/use-docs-ruler-margin-drag.ts | 155 +++ components/drive/share-dialog.tsx | 907 +++++++++--------- components/ui/select.tsx | 9 +- lib/drive/doc-page-setup.ts | 57 ++ lib/drive/docs-graphic-assets.ts | 89 ++ lib/drive/docs-graphic-import.ts | 206 ++++ lib/drive/docs-graphic-layout.ts | 151 +++ lib/drive/docs-graphic-types.ts | 256 +++++ lib/drive/docs-graphic.test.ts | 76 ++ lib/drive/docs-header-footer-layout.ts | 203 ++++ lib/drive/docs-keyboard-shortcuts-config.ts | 11 +- lib/drive/docs-page-flow.test.ts | 33 + lib/drive/docs-page-layout-constants.ts | 8 + lib/drive/docs-page-metrics.test.ts | 36 + lib/drive/docs-page-metrics.ts | 92 ++ lib/drive/docs-ruler-margin-math.ts | 73 ++ lib/drive/docs-ruler-margin.test.ts | 26 + lib/drive/docs-ruler-math.ts | 65 ++ lib/drive/docs-ruler-scale.ts | 17 + lib/drive/docs-ruler-sync-math.ts | 32 + lib/drive/docs-ruler-sync.test.ts | 37 + lib/drive/docs-ruler-units.ts | 58 ++ lib/drive/docs-ruler.test.ts | 43 + lib/drive/docs-view-settings.ts | 91 ++ lib/drive/docx-drawing-import.ts | 326 +++++++ lib/drive/docx-drawing-vml.ts | 12 + lib/drive/docx-header-footer-import.ts | 96 ++ lib/drive/docx-position-import.ts | 223 +++++ lib/drive/drive-dialog-styles.ts | 12 +- .../extensions/docs-graphic-paste-drop.ts | 71 ++ lib/drive/extensions/docs-graphic.ts | 240 +++++ .../extensions/docs-page-flow-decoration.ts | 209 ++++ lib/drive/focus-editor-at-pointer.ts | 13 +- lib/drive/richtext-extensions.ts | 40 + lib/drive/richtext-import.test.ts | 111 +++ lib/drive/richtext-import.ts | 78 +- lib/drive/use-docs-ruler-sync.ts | 185 ++++ styles/richtext-editor.css | 390 +++++++- tsconfig.tsbuildinfo | 2 +- 62 files changed, 8273 insertions(+), 781 deletions(-) create mode 100644 components/drive/richtext/docs-body-margin-masks.tsx create mode 100644 components/drive/richtext/docs-editor-workspace.tsx create mode 100644 components/drive/richtext/docs-exclusive-menu-sub.tsx create mode 100644 components/drive/richtext/docs-graphic-context-menu.tsx create mode 100644 components/drive/richtext/docs-graphic-crop-overlay.tsx create mode 100644 components/drive/richtext/docs-graphic-node-view.tsx create mode 100644 components/drive/richtext/docs-graphic-options-panel.tsx create mode 100644 components/drive/richtext/docs-graphic-toolbar-menu.tsx create mode 100644 components/drive/richtext/docs-header-footer-dialogs.tsx create mode 100644 components/drive/richtext/docs-header-footer-region.tsx create mode 100644 components/drive/richtext/docs-horizontal-ruler.tsx create mode 100644 components/drive/richtext/docs-region-editor.tsx create mode 100644 components/drive/richtext/docs-ruler-markers.tsx create mode 100644 components/drive/richtext/docs-rulers-chrome.tsx create mode 100644 components/drive/richtext/docs-vertical-ruler.tsx create mode 100644 components/drive/richtext/docs-view-menu.tsx create mode 100644 components/drive/richtext/use-docs-ruler-margin-drag.ts create mode 100644 lib/drive/docs-graphic-assets.ts create mode 100644 lib/drive/docs-graphic-import.ts create mode 100644 lib/drive/docs-graphic-layout.ts create mode 100644 lib/drive/docs-graphic-types.ts create mode 100644 lib/drive/docs-graphic.test.ts create mode 100644 lib/drive/docs-header-footer-layout.ts create mode 100644 lib/drive/docs-page-flow.test.ts create mode 100644 lib/drive/docs-page-layout-constants.ts create mode 100644 lib/drive/docs-page-metrics.test.ts create mode 100644 lib/drive/docs-page-metrics.ts create mode 100644 lib/drive/docs-ruler-margin-math.ts create mode 100644 lib/drive/docs-ruler-margin.test.ts create mode 100644 lib/drive/docs-ruler-math.ts create mode 100644 lib/drive/docs-ruler-scale.ts create mode 100644 lib/drive/docs-ruler-sync-math.ts create mode 100644 lib/drive/docs-ruler-sync.test.ts create mode 100644 lib/drive/docs-ruler-units.ts create mode 100644 lib/drive/docs-ruler.test.ts create mode 100644 lib/drive/docx-drawing-import.ts create mode 100644 lib/drive/docx-drawing-vml.ts create mode 100644 lib/drive/docx-header-footer-import.ts create mode 100644 lib/drive/docx-position-import.ts create mode 100644 lib/drive/extensions/docs-graphic-paste-drop.ts create mode 100644 lib/drive/extensions/docs-graphic.ts create mode 100644 lib/drive/extensions/docs-page-flow-decoration.ts create mode 100644 lib/drive/richtext-import.test.ts create mode 100644 lib/drive/use-docs-ruler-sync.ts diff --git a/app/globals.css b/app/globals.css index e967a34..5e77a14 100644 --- a/app/globals.css +++ b/app/globals.css @@ -999,12 +999,35 @@ html.dark :where( } html.dark :where([data-slot='dialog-content'], [data-slot='sheet-content']) - :where([data-slot='input'], textarea, [data-slot='select-trigger']) { + :where([data-slot='input'], textarea, [data-slot='select-trigger']:not([data-variant='ghost'])) { background-color: var(--mail-surface-muted) !important; border-color: var(--mail-border) !important; color: var(--mail-text) !important; } +/* Drive dialogs — fond plus foncé que les modales mail */ +html.dark .drive-dialog[data-slot='dialog-content'], +html.dark .drive-dialog[data-slot='sheet-content'] { + background-color: #292a2d !important; + border-color: #3c4043 !important; +} + +html.dark .drive-dialog[data-slot='dialog-content'] [data-slot='dialog-footer'], +html.dark .drive-dialog[data-slot='sheet-content'] [data-slot='sheet-footer'] { + background-color: #252628 !important; + border-color: #3c4043 !important; +} + +html.dark .drive-dialog [data-slot='select-trigger'][data-variant='ghost'] { + background-color: transparent !important; + border-color: transparent !important; +} + +html.dark .drive-dialog [data-slot='select-trigger'][data-variant='ghost']:hover, +html.dark .drive-dialog [data-slot='select-trigger'][data-variant='ghost'][data-state='open'] { + background-color: transparent !important; +} + html.dark :where([data-slot='dialog-content'], [data-slot='sheet-content']) fieldset.border { border-color: var(--mail-border) !important; } diff --git a/components/drive/richtext-document.tsx b/components/drive/richtext-document.tsx index 7f9145b..e675af4 100644 --- a/components/drive/richtext-document.tsx +++ b/components/drive/richtext-document.tsx @@ -7,17 +7,21 @@ import { HocuspocusProvider } from "@hocuspocus/provider" import * as Y from "yjs" import { toast } from "sonner" import { DocsChrome } from "@/components/drive/richtext/docs-chrome" -import { DocsPageView, DocsStatusBar } from "@/components/drive/richtext/docs-page-view" +import { DocsEditorWorkspace } from "@/components/drive/richtext/docs-editor-workspace" +import { DocsStatusBar } from "@/components/drive/richtext/docs-page-view" 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 type { DocsViewMenuActions, DocsViewMenuState } from "@/components/drive/richtext/docs-view-menu" import { useDocsViewSettings } from "@/lib/drive/docs-view-settings" +import { useDocsKeyboardShortcutsStore } from "@/lib/stores/docs-keyboard-shortcuts-store" import { useCollabPresence } from "@/lib/drive/use-collab-presence" import { apiClient } from "@/lib/api/client" import { driveDownloadApiPath } from "@/lib/api/drive-download" +import { buildRegionPatch } from "@/lib/drive/docs-header-footer-layout" import { buildPageSetupForFormat, buildPageSetupFromDraft, @@ -27,11 +31,14 @@ import { import { readUserPageSetupDefaults } from "@/lib/drive/docs-page-defaults" import { isEmptyTipTapDoc } from "@/lib/drive/richtext-content" import { importFileToTipTap } from "@/lib/drive/richtext-import" +import { migrateBase64ImagesInContent } from "@/lib/drive/docs-graphic-assets" import { isUltidocPath } from "@/lib/drive/richtext-formats" +import { cn } from "@/lib/utils" const SAVE_DEBOUNCE_MS = 2000 /** Align with Hocuspocus store debounce + buffer */ const COLLAB_SAVE_IDLE_MS = 2000 +const PAGE_SETUP_DEBOUNCE_MS = 1500 export type RichTextDocsChromeProps = { title: string @@ -94,13 +101,32 @@ export function RichTextDocumentEditor({ ) const [saveStatus, setSaveStatus] = useState("idle") const [pageCount, setPageCount] = useState(1) + const [currentPage, setCurrentPage] = useState(1) + const [regionEditor, setRegionEditor] = useState(null) const saveTimer = useRef | null>(null) const saveIdleTimer = useRef | null>(null) + const pageSetupTimer = useRef | null>(null) + const documentPageSetupRef = useRef(session.pageSetup ?? null) const saveStatusRef = useRef("idle") const reloadAfterReimportRef = useRef(false) + const purgeReimportingRef = useRef(false) + const providerRef = useRef(null) - const { settings, setPageFormatId, setZoom, toggleSpellcheck, toggleChromeCollapsed } = - useDocsViewSettings() + const { + settings, + setPageFormatId, + setZoom, + toggleSpellcheck, + toggleChromeCollapsed, + setEditorMode, + setCommentsDisplay, + toggleOutlineSidebarExpanded, + toggleShowLayout, + toggleShowRuler, + toggleShowEquationToolbar, + toggleShowNonPrintableChars, + } = useDocsViewSettings() + const shellRef = useRef(null) const presenceUsers = useCollabPresence(provider, { name: userName, color: userColor }) const pageLayout = useMemo( () => resolveDocumentPageLayout(documentPageSetup, settings.pageFormatId), @@ -120,9 +146,6 @@ export function RichTextDocumentEditor({ const persistPageSetup = useCallback( async (setup: DocPageSetup) => { if (!editable) return - setDocumentPageSetup(setup) - if (setup.formatId) setPageFormatId(setup.formatId) - reportSaveStatus("saving") try { const body = JSON.stringify({ pageSetup: setup }) if (session.saveUrl) { @@ -143,16 +166,80 @@ export function RichTextDocumentEditor({ reportSaveStatus("error") } }, - [editable, reportSaveStatus, session.canonicalPath, session.saveUrl, setPageFormatId] + [editable, reportSaveStatus, session.canonicalPath, session.saveUrl] + ) + + const schedulePageSetupPatch = useCallback( + (patch: Partial, options?: { immediate?: boolean }) => { + const base = + documentPageSetupRef.current ?? + buildPageSetupForFormat(settings.pageFormatId, null) + const next = { ...base, ...patch } + documentPageSetupRef.current = next + setDocumentPageSetup(next) + if (next.formatId) setPageFormatId(next.formatId) + + const flush = () => { + const setup = documentPageSetupRef.current + if (!setup) return + void persistPageSetup(setup) + } + + if (options?.immediate) { + if (pageSetupTimer.current) clearTimeout(pageSetupTimer.current) + if (saveStatusRef.current === "idle") { + reportSaveStatus("saving") + } + flush() + return + } + + if (saveStatusRef.current === "idle") { + reportSaveStatus("saving") + } + if (pageSetupTimer.current) clearTimeout(pageSetupTimer.current) + pageSetupTimer.current = setTimeout(flush, PAGE_SETUP_DEBOUNCE_MS) + }, + [persistPageSetup, reportSaveStatus, settings.pageFormatId, setPageFormatId] ) const handlePageFormatChange = useCallback( (formatId: typeof settings.pageFormatId) => { - void persistPageSetup(buildPageSetupForFormat(formatId, documentPageSetup)) + schedulePageSetupPatch(buildPageSetupForFormat(formatId, documentPageSetupRef.current), { + immediate: true, + }) }, - [documentPageSetup, persistPageSetup, settings.pageFormatId] + [schedulePageSetupPatch, settings.pageFormatId] ) + const handleRegionContentChange = useCallback( + ( + region: "header" | "footer", + content: Record, + meta: { pageIndex: number; contentHeightPx: number }, + options?: { immediate?: boolean } + ) => { + const base = + documentPageSetupRef.current ?? buildPageSetupForFormat(settings.pageFormatId, null) + schedulePageSetupPatch( + buildRegionPatch(base, region, meta.pageIndex, content, meta.contentHeightPx), + options + ) + }, + [schedulePageSetupPatch, settings.pageFormatId] + ) + + const handlePageSetupPatch = useCallback( + (patch: Partial, options?: { immediate?: boolean }) => { + schedulePageSetupPatch(patch, options) + }, + [schedulePageSetupPatch] + ) + + useEffect(() => { + documentPageSetupRef.current = documentPageSetup + }, [documentPageSetup]) + useEffect(() => { if (session.pageSetup) setDocumentPageSetup(session.pageSetup) }, [session.pageSetup]) @@ -172,12 +259,20 @@ export function RichTextDocumentEditor({ const handlePageCountChange = useCallback((count: number) => { setPageCount(count) + setCurrentPage((page) => Math.min(page, count)) + }, []) + + const handleCurrentPageChange = useCallback((page: number) => { + setCurrentPage(page) }, []) const handlePurgeSidecarAndReimport = useCallback(async () => { if (!editable) return - const source = session.sourcePath || session.canonicalPath - if (!source || isUltidocPath(source)) { + if (!session.sourcePath) { + toast.error("Chemin source introuvable — ouvrez le document depuis le fichier DOCX") + return + } + if (isUltidocPath(session.sourcePath)) { toast.error("Aucun fichier source à réimporter") return } @@ -190,15 +285,29 @@ export function RichTextDocumentEditor({ } reportSaveStatus("saving") + purgeReimportingRef.current = true + reloadAfterReimportRef.current = true + try { + providerRef.current?.destroy() + providerRef.current = null + setProvider(null) + setCollabSynced(false) + + if (ydocRef.current) { + ydocRef.current.destroy() + ydocRef.current = collaboration ? new Y.Doc() : null + } + await apiClient.delete(`/drive/files${session.canonicalPath}`) setDocumentPageSetup(null) setImportedContent(null) - reloadAfterReimportRef.current = collaboration setImportDone(false) setContentImportPending(true) toast.success("Sidecar purgé — réimport en cours…") } catch { + purgeReimportingRef.current = false + reloadAfterReimportRef.current = false reportSaveStatus("error") toast.error("Impossible de purger le sidecar") } @@ -218,6 +327,7 @@ export function RichTextDocumentEditor({ return () => { if (saveTimer.current) clearTimeout(saveTimer.current) if (saveIdleTimer.current) clearTimeout(saveIdleTimer.current) + if (pageSetupTimer.current) clearTimeout(pageSetupTimer.current) } }, []) @@ -258,12 +368,18 @@ export function RichTextDocumentEditor({ void (async () => { reportSaveStatus("saving") try { - const source = session.sourcePath || session.canonicalPath + const source = session.sourcePath + if (!source) { + throw new Error("Chemin source manquant pour le réimport") + } const buf = fetchSourceBytes ? await fetchSourceBytes(source) : await (await apiClient.getBlob(driveDownloadApiPath(source))).arrayBuffer() const imported = await importFileToTipTap(source.split("/").pop() ?? "file.docx", buf) if (cancelled) return + if (isEmptyTipTapDoc(imported.content as Record)) { + throw new Error("Le fichier source n'a produit aucun contenu importable") + } const payload = { source_path: source, content: imported.content, @@ -275,27 +391,41 @@ export function RichTextDocumentEditor({ await apiClient.post("/richtext/import", payload) } if (!cancelled) { - setImportedContent(imported.content as Record) if (imported.pageSetup) setDocumentPageSetup(imported.pageSetup) - setContentImportPending(false) - setImportDone(true) - reportSaveStatus("saved") if (reloadAfterReimportRef.current) { reloadAfterReimportRef.current = false window.location.reload() return } + setImportedContent(imported.content as Record) + setContentImportPending(false) + setImportDone(true) + purgeReimportingRef.current = false + reportSaveStatus("saved") + } + } catch (error) { + if (!cancelled) { + purgeReimportingRef.current = false + reloadAfterReimportRef.current = false + reportSaveStatus("error") + toast.error(error instanceof Error ? error.message : "Réimport échoué") } - } catch { - if (!cancelled) reportSaveStatus("error") } })() return () => { cancelled = true } - }, [contentImportPending, importDone, session, fetchSourceBytes, importApi, reportSaveStatus]) + }, [ + contentImportPending, + importDone, + session.sourcePath, + fetchSourceBytes, + importApi, + reportSaveStatus, + ]) useEffect(() => { + if (purgeReimportingRef.current) return if (!collaboration || !ydoc || !importDone) return setCollabSynced(false) @@ -313,10 +443,12 @@ export function RichTextDocumentEditor({ setCollabSynced(false) }, }) + providerRef.current = p setProvider(p) return () => { p.destroy() + providerRef.current = null setProvider(null) setCollabSynced(false) } @@ -330,20 +462,33 @@ export function RichTextDocumentEditor({ reportSaveStatus("saving") } saveTimer.current = setTimeout(() => { - const doc = { schemaVersion: 1, editor: "tiptap", content: json } - const body = JSON.stringify(doc) - const savePromise = session.saveUrl - ? fetch(session.saveUrl, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body, - }).then((res) => { - if (!res.ok) throw new Error("save failed") + void (async () => { + let content = json + try { + content = await migrateBase64ImagesInContent(json, async ({ dataUrl }) => { + const res = await apiClient.post<{ assetId: string; url: string }>( + "/richtext/assets", + { path: session.canonicalPath, dataUrl } + ) + return { assetId: res.assetId, url: res.url } }) - : apiClient.put("/richtext/save", { path: session.canonicalPath, document: json }) - void savePromise - .then(() => reportSaveStatus("saved")) - .catch(() => reportSaveStatus("error")) + } catch { + /* keep base64 fallback */ + } + const doc = { schemaVersion: 1, editor: "tiptap", content } + const body = JSON.stringify(doc) + const savePromise = session.saveUrl + ? fetch(session.saveUrl, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body, + }).then((res) => { + if (!res.ok) throw new Error("save failed") + }) + : apiClient.put("/richtext/save", { path: session.canonicalPath, document: content }) + await savePromise + reportSaveStatus("saved") + })().catch(() => reportSaveStatus("error")) }, SAVE_DEBOUNCE_MS) }, [collaboration, editable, reportSaveStatus, session.canonicalPath, session.saveUrl] @@ -416,7 +561,10 @@ export function RichTextDocumentEditor({ editor, pageSetup: documentPageSetup, fallbackFormatId: settings.pageFormatId, - onPageSetupApply: (setup) => void persistPageSetup(setup), + onPageSetupApply: (setup) => { + documentPageSetupRef.current = setup + schedulePageSetupPatch(setup, { immediate: true }) + }, onPurgeSidecarAndReimport: () => void handlePurgeSidecarAndReimport(), onShareClick: chrome?.onShareClick, onRenameRequest: chrome?.onRenameRequest, @@ -429,6 +577,51 @@ export function RichTextDocumentEditor({ disabled: !editable, }) + const handleFullscreen = useCallback(() => { + const el = shellRef.current + if (!el) return + if (document.fullscreenElement) { + void document.exitFullscreen() + return + } + void el.requestFullscreen?.() + }, []) + + const viewMenuState = useMemo( + () => ({ + editorMode: settings.editorMode, + commentsDisplay: settings.commentsDisplay, + showLayout: settings.showLayout, + showRuler: settings.showRuler, + showEquationToolbar: settings.showEquationToolbar, + showNonPrintableChars: settings.showNonPrintableChars, + }), + [settings] + ) + + const viewMenuActions = useMemo( + () => ({ + onEditorModeChange: setEditorMode, + onCommentsDisplayChange: setCommentsDisplay, + onToggleOutlineSidebar: toggleOutlineSidebarExpanded, + onToggleShowLayout: toggleShowLayout, + onToggleShowRuler: toggleShowRuler, + onToggleShowEquationToolbar: toggleShowEquationToolbar, + onToggleShowNonPrintableChars: toggleShowNonPrintableChars, + onFullscreen: handleFullscreen, + }), + [ + handleFullscreen, + setCommentsDisplay, + setEditorMode, + toggleOutlineSidebarExpanded, + toggleShowEquationToolbar, + toggleShowLayout, + toggleShowNonPrintableChars, + toggleShowRuler, + ] + ) + const chromeProps = chrome ? { ...chrome, @@ -438,9 +631,34 @@ export function RichTextDocumentEditor({ editMenuActions: editMenu.actions, editMenuState: editMenu.state, editMenuDisabled: editMenu.disabled, + viewMenuActions, + viewMenuState, + viewMenuDisabled: false, } : undefined + useEffect(() => { + if (!editor || editor.isDestroyed) return + const canEdit = editable && settings.editorMode !== "view" + editor.setEditable(canEdit) + }, [editor, editable, settings.editorMode]) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const id = useDocsKeyboardShortcutsStore.getState().matchEvent( + event, + (definition) => + definition.scope === "document" && definition.handler === "custom" + ) + if (id === "view.showNonPrintable") { + event.preventDefault() + toggleShowNonPrintableChars() + } + } + window.addEventListener("keydown", onKeyDown) + return () => window.removeEventListener("keydown", onKeyDown) + }, [toggleShowNonPrintableChars]) + useEffect(() => { if (!editor || collaboration || !importDone) return @@ -522,10 +740,6 @@ export function RichTextDocumentEditor({ {...chromeProps} saveStatus={saveStatus} presenceUsers={presenceUsers} - pageFormatId={activePageFormatId} - onPageFormatChange={handlePageFormatChange} - zoom={settings.zoom} - onZoomChange={setZoom} /> ) : null}
@@ -536,37 +750,55 @@ export function RichTextDocumentEditor({ } return ( -
+
{chromeProps && !settings.chromeCollapsed ? ( - ) : null} - {editable ? ( - ) : null} {chrome ? ( - + ) : null + } /> ) : (
@@ -574,7 +806,11 @@ export function RichTextDocumentEditor({
)} {chrome ? ( - + ) : null}
) diff --git a/components/drive/richtext/docs-body-margin-masks.tsx b/components/drive/richtext/docs-body-margin-masks.tsx new file mode 100644 index 0000000..8001263 --- /dev/null +++ b/components/drive/richtext/docs-body-margin-masks.tsx @@ -0,0 +1,65 @@ +"use client" + +import { DOCS_PAGE_GAP_PX } from "@/lib/drive/docs-page-layout-constants" +import type { DocPageLayout } from "@/lib/drive/doc-page-setup" +import { + pageFooterGeometry, + pageHeaderGeometry, +} from "@/lib/drive/docs-header-footer-layout" + +/** Hides body text that bleeds into header/footer margin bands on each page. */ +export function DocsBodyMarginMasks({ + pageCount, + pageLayout, + pageWidth, + pageHeight, + pageColor, + pageRegionHeights, +}: { + pageCount: number + pageLayout: DocPageLayout + pageWidth: number + pageHeight: number + pageColor: string + pageRegionHeights?: Record +}) { + return ( + <> + {Array.from({ length: pageCount }, (_, index) => { + const pageTop = index * (pageHeight + DOCS_PAGE_GAP_PX) + const header = pageHeaderGeometry(pageLayout, pageTop, index) + const footer = pageFooterGeometry(pageLayout, pageTop, pageHeight, index) + const headerHeight = + pageRegionHeights?.[`header-${index}`] ?? header.zoneHeight + const footerHeight = + pageRegionHeights?.[`footer-${index}`] ?? footer.zoneHeight + const headerBottom = header.zoneTop + headerHeight + const footerTop = footer.zoneBottom - footerHeight + return ( +
+
+
+
+ ) + })} + + ) +} diff --git a/components/drive/richtext/docs-chrome.tsx b/components/drive/richtext/docs-chrome.tsx index 7fcb671..4e27da8 100644 --- a/components/drive/richtext/docs-chrome.tsx +++ b/components/drive/richtext/docs-chrome.tsx @@ -11,6 +11,7 @@ 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 type { DocsViewMenuActions, DocsViewMenuState } from "@/components/drive/richtext/docs-view-menu" import { DocsMoveButton } from "@/components/drive/richtext/docs-move-button" import { Button } from "@/components/ui/button" import type { DriveShare, DriveFileInfo } from "@/lib/api/types" @@ -19,7 +20,6 @@ import { resolveShareButtonIcon, type ShareButtonIcon, } from "@/lib/drive/drive-share-button-state" -import type { PageFormatId } from "@/lib/drive/page-formats" import type { RichTextSaveStatus } from "@/lib/drive/richtext-types" import { cn } from "@/lib/utils" @@ -55,10 +55,9 @@ export function DocsChrome({ showAccount = false, saveStatus = "idle", presenceUsers = [], - pageFormatId, - onPageFormatChange, - zoom, - onZoomChange, + viewMenuActions, + viewMenuState, + viewMenuDisabled, trailing, moveFile, onFileMoved, @@ -82,10 +81,9 @@ export function DocsChrome({ showAccount?: boolean saveStatus?: RichTextSaveStatus presenceUsers?: CollabPresenceUser[] - pageFormatId: PageFormatId - onPageFormatChange: (id: PageFormatId) => void - zoom: number - onZoomChange: (zoom: number) => void + viewMenuActions?: DocsViewMenuActions + viewMenuState?: DocsViewMenuState + viewMenuDisabled?: boolean trailing?: ReactNode /** Propriétaire uniquement — affiche le bouton déplacer. */ moveFile?: DriveFileInfo @@ -164,10 +162,9 @@ export function DocsChrome({
void + onPageCountChange?: (count: number) => void + onCurrentPageChange?: (page: number) => void + toolbar?: ReactNode + toolbarShellClassName?: string + onRegionContentChange?: ( + region: "header" | "footer", + content: Record, + meta: { pageIndex: number; contentHeightPx: number } + ) => void + onPageSetupChange?: ( + patch: Partial, + options?: { immediate?: boolean } + ) => void + onRegionEditorChange?: (editor: import("@tiptap/react").Editor | null) => void +}) { + const canvasRef = useRef(null) + const rulerTrackRef = useRef(null) + const [pageCount, setPageCount] = useState(1) + const [narrowViewport, setNarrowViewport] = useState(false) + + const rulersVisible = showLayout && showRuler + const scale = docsZoomToScale(zoom) + const showToolbarShell = Boolean(toolbar) || rulersVisible + const marginsEditable = editable && editorMode !== "view" + + const { + pageLayoutWithMargins, + beginMarginDrag, + moveMarginDrag, + endMarginDrag, + dragTooltip, + } = useDocsRulerMarginDrag({ + pageLayout, + editable: marginsEditable, + onPageSetupChange, + }) + + const rulerSync = useDocsRulerSync({ + canvasRef, + rulerTrackRef, + editor, + pageLayout: pageLayoutWithMargins, + zoom, + pageCount, + narrowViewport, + }) + + useEffect(() => { + onCurrentPageChange?.(rulerSync.currentPage + 1) + }, [onCurrentPageChange, rulerSync.currentPage]) + + return ( +
+ + {showToolbarShell ? ( +
+ {toolbar} + {rulersVisible ? ( + + ) : null} +
+ ) : null} + +
+ {rulersVisible ? ( + + ) : null} + +
+ { + setPageCount(count) + onPageCountChange?.(count) + }} + onNarrowViewportChange={setNarrowViewport} + onRegionContentChange={onRegionContentChange} + onPageSetupChange={onPageSetupChange} + onRegionEditorChange={onRegionEditorChange} + /> +
+
+
+ ) +} diff --git a/components/drive/richtext/docs-exclusive-menu-sub.tsx b/components/drive/richtext/docs-exclusive-menu-sub.tsx new file mode 100644 index 0000000..6d2bfda --- /dev/null +++ b/components/drive/richtext/docs-exclusive-menu-sub.tsx @@ -0,0 +1,59 @@ +"use client" + +import { + createContext, + useCallback, + useContext, + useId, + useState, + type ReactNode, +} from "react" +import { MenubarSub } from "@/components/ui/menubar" + +type ExclusiveMenuSubContextValue = { + openId: string | null + setOpenId: (id: string | null) => void +} + +const ExclusiveMenuSubContext = createContext(null) + +/** Ensures only one MenubarSub stays open while hovering across sibling sub-triggers. */ +export function DocsExclusiveMenuSubRoot({ children }: { children: ReactNode }) { + const [openId, setOpenId] = useState(null) + return ( + + {children} + + ) +} + +export function DocsExclusiveMenuSub({ + id, + children, +}: { + id: string + children: ReactNode +}) { + const ctx = useContext(ExclusiveMenuSubContext) + const fallbackId = useId() + const subId = id || fallbackId + + const open = ctx?.openId === subId + const onOpenChange = useCallback( + (next: boolean) => { + if (!ctx) return + ctx.setOpenId(next ? subId : null) + }, + [ctx, subId] + ) + + if (!ctx) { + return {children} + } + + return ( + + {children} + + ) +} diff --git a/components/drive/richtext/docs-file-menu.tsx b/components/drive/richtext/docs-file-menu.tsx index 544faff..6036f0e 100644 --- a/components/drive/richtext/docs-file-menu.tsx +++ b/components/drive/richtext/docs-file-menu.tsx @@ -25,11 +25,14 @@ import { MenubarItem, MenubarMenu, MenubarSeparator, - MenubarSub, MenubarSubContent, MenubarSubTrigger, MenubarTrigger, } from "@/components/ui/menubar" +import { + DocsExclusiveMenuSub, + DocsExclusiveMenuSubRoot, +} from "@/components/drive/richtext/docs-exclusive-menu-sub" 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" @@ -81,7 +84,8 @@ export function DocsFileMenu({ className="docs-menu-content min-w-[280px] overflow-visible" data-docs-menu-surface > - + + @@ -106,7 +110,7 @@ export function DocsFileMenu({ À partir de la galerie de modèles - + @@ -125,7 +129,7 @@ export function DocsFileMenu({ - + @@ -146,9 +150,9 @@ export function DocsFileMenu({ Publier sur le Web - + - + @@ -170,9 +174,9 @@ export function DocsFileMenu({ Brouillon d'e-mail - + - + @@ -191,7 +195,7 @@ export function DocsFileMenu({ ))} - + @@ -229,7 +233,7 @@ export function DocsFileMenu({ - + @@ -252,7 +256,7 @@ export function DocsFileMenu({ Afficher l'historique des versions - + @@ -307,6 +311,7 @@ export function DocsFileMenu({ Imprimer + ) diff --git a/components/drive/richtext/docs-graphic-context-menu.tsx b/components/drive/richtext/docs-graphic-context-menu.tsx new file mode 100644 index 0000000..5d030e1 --- /dev/null +++ b/components/drive/richtext/docs-graphic-context-menu.tsx @@ -0,0 +1,136 @@ +"use client" + +import type { Editor } from "@tiptap/react" +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from "@/components/ui/context-menu" +import { + DOCS_GRAPHIC_WRAP_LABELS, + parseGraphicAttrs, + type DocsGraphicWrap, +} from "@/lib/drive/docs-graphic-types" + +export function DocsGraphicContextMenu({ + editor, + children, + onCrop, + onOpenOptions, + onReplaceImage, +}: { + editor: Editor + children: React.ReactNode + onCrop?: () => void + onOpenOptions?: () => void + onReplaceImage?: () => void +}) { + const active = + editor.isActive("docsGraphic") || editor.isActive("docsInlineGraphic") + if (!active) return <>{children} + + const name = editor.isActive("docsInlineGraphic") ? "docsInlineGraphic" : "docsGraphic" + const attrs = parseGraphicAttrs(editor.getAttributes(name) as Record) + const isImage = attrs.graphicType === "image" + + const applyWrap = (wrap: DocsGraphicWrap) => { + editor.chain().focus().setDocsGraphicWrap(wrap).run() + } + + const downloadImage = () => { + if (!attrs.src) return + const link = document.createElement("a") + link.href = attrs.src + link.download = "image" + link.click() + } + + return ( + + {children} + + document.execCommand("cut")} + > + Couper + + document.execCommand("copy")} + > + Copier + + document.execCommand("paste")} + > + Coller + + editor.chain().focus().deleteSelection().run()} + > + Supprimer + + + {isImage ? ( + <> + Remplacer l'image… + Recadrer + Options image… + { + const alt = window.prompt("Texte alternatif", attrs.alt) + if (alt != null) editor.chain().focus().updateDocsGraphic({ alt }).run() + }} + > + Texte alternatif… + + + Télécharger l'image + + + ) : attrs.graphicType === "shape" ? ( + <> + { + const fill = window.prompt("Couleur de remplissage", attrs.fill) + if (fill) editor.chain().focus().updateDocsGraphic({ fill }).run() + }} + > + Modifier le remplissage… + + { + const stroke = window.prompt("Couleur du contour", attrs.stroke) + if (stroke) editor.chain().focus().updateDocsGraphic({ stroke }).run() + }} + > + Modifier le contour… + + + ) : null} + + + Habillage texte + + {(Object.keys(DOCS_GRAPHIC_WRAP_LABELS) as DocsGraphicWrap[]).map((wrap) => ( + applyWrap(wrap)}> + {DOCS_GRAPHIC_WRAP_LABELS[wrap]} + + ))} + + + + editor.chain().focus().bringDocsGraphicForward().run()}> + Avancer + + editor.chain().focus().sendDocsGraphicBackward().run()}> + Reculer + + + + ) +} diff --git a/components/drive/richtext/docs-graphic-crop-overlay.tsx b/components/drive/richtext/docs-graphic-crop-overlay.tsx new file mode 100644 index 0000000..4f16a12 --- /dev/null +++ b/components/drive/richtext/docs-graphic-crop-overlay.tsx @@ -0,0 +1,143 @@ +"use client" + +import { useCallback, useEffect, useRef } from "react" +import type { DocsGraphicAttrs } from "@/lib/drive/docs-graphic-types" +import { cn } from "@/lib/utils" + +const HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"] as const +type Handle = (typeof HANDLES)[number] + +const HANDLE_CLASS: Record = { + nw: "left-0 top-0 cursor-nwse-resize", + n: "left-1/2 top-0 -translate-x-1/2 cursor-ns-resize", + ne: "right-0 top-0 cursor-nesw-resize", + e: "right-0 top-1/2 -translate-y-1/2 cursor-ew-resize", + se: "right-0 bottom-0 cursor-nwse-resize", + s: "left-1/2 bottom-0 -translate-x-1/2 cursor-ns-resize", + sw: "left-0 bottom-0 cursor-nesw-resize", + w: "left-0 top-1/2 -translate-y-1/2 cursor-ew-resize", +} + +function clamp01(v: number): number { + return Math.min(1, Math.max(0, v)) +} + +function resizeCrop( + handle: Handle, + crop: Pick, + dxNorm: number, + dyNorm: number +): Pick { + let { cropX, cropY, cropWidth, cropHeight } = crop + if (handle.includes("e")) cropWidth = clamp01(cropWidth + dxNorm) + if (handle.includes("w")) { + cropWidth = clamp01(cropWidth - dxNorm) + cropX = clamp01(cropX + dxNorm) + } + if (handle.includes("s")) cropHeight = clamp01(cropHeight + dyNorm) + if (handle.includes("n")) { + cropHeight = clamp01(cropHeight - dyNorm) + cropY = clamp01(cropY + dyNorm) + } + cropWidth = Math.max(0.05, cropWidth) + cropHeight = Math.max(0.05, cropHeight) + return { cropX, cropY, cropWidth, cropHeight } +} + +export function DocsGraphicCropOverlay({ + attrs, + frameWidth, + frameHeight, + onChange, + onDone, +}: { + attrs: DocsGraphicAttrs + frameWidth: number + frameHeight: number + onChange: (patch: Partial) => void + onDone: () => void +}) { + const dragRef = useRef<{ + handle: Handle + startX: number + startY: number + origin: Pick + } | null>(null) + + const onHandleDown = useCallback( + (handle: Handle, event: React.PointerEvent) => { + event.preventDefault() + event.stopPropagation() + ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId) + dragRef.current = { + handle, + startX: event.clientX, + startY: event.clientY, + origin: { + cropX: attrs.cropX, + cropY: attrs.cropY, + cropWidth: attrs.cropWidth, + cropHeight: attrs.cropHeight, + }, + } + }, + [attrs.cropHeight, attrs.cropWidth, attrs.cropX, attrs.cropY] + ) + + useEffect(() => { + const onMove = (event: PointerEvent) => { + if (!dragRef.current) return + const { handle, startX, startY, origin } = dragRef.current + const dxNorm = (event.clientX - startX) / Math.max(frameWidth, 1) + const dyNorm = (event.clientY - startY) / Math.max(frameHeight, 1) + onChange(resizeCrop(handle, origin, dxNorm, dyNorm)) + } + const onUp = () => { + dragRef.current = null + } + window.addEventListener("pointermove", onMove) + window.addEventListener("pointerup", onUp) + return () => { + window.removeEventListener("pointermove", onMove) + window.removeEventListener("pointerup", onUp) + } + }, [frameHeight, frameWidth, onChange]) + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") onDone() + } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, [onDone]) + + const left = `${attrs.cropX * 100}%` + const top = `${attrs.cropY * 100}%` + const width = `${attrs.cropWidth * 100}%` + const height = `${attrs.cropHeight * 100}%` + + return ( +
+
+
+ {HANDLES.map((handle) => ( + onHandleDown(handle, event)} + /> + ))} +
+
+ ) +} diff --git a/components/drive/richtext/docs-graphic-node-view.tsx b/components/drive/richtext/docs-graphic-node-view.tsx new file mode 100644 index 0000000..6ac05c0 --- /dev/null +++ b/components/drive/richtext/docs-graphic-node-view.tsx @@ -0,0 +1,470 @@ +"use client" + +import { memo, useCallback, useEffect, useRef, useState } from "react" +import { NodeViewWrapper, type NodeViewProps } from "@tiptap/react" +import { + computeGraphicLayoutStyle, + resizeWithHandle, + type ResizeHandle, + RESIZE_HANDLES, +} from "@/lib/drive/docs-graphic-layout" +import { + computeCropImageStyle, + parseGraphicAttrs, + type DocsGraphicAttrs, + type DocsShapeType, +} from "@/lib/drive/docs-graphic-types" +import { DocsGraphicCropOverlay } from "@/components/drive/richtext/docs-graphic-crop-overlay" +import { DocsGraphicContextMenu } from "@/components/drive/richtext/docs-graphic-context-menu" +import { cn } from "@/lib/utils" + +function ShapePreview({ + shapeType, + fill, + stroke, + strokeWidth, +}: { + shapeType: DocsShapeType + fill: string + stroke: string + strokeWidth: number +}) { + if (shapeType === "ellipse") { + return ( + + + + ) + } + if (shapeType === "line") { + return ( + + + + ) + } + if (shapeType === "arrow") { + return ( + + + + + ) + } + return ( + + + + ) +} + +function GraphicContent({ attrs }: { attrs: DocsGraphicAttrs }) { + if (attrs.graphicType === "image") { + if (!attrs.src) { + return ( +
+ Image +
+ ) + } + const cropStyle = computeCropImageStyle(attrs) + return ( + {attrs.alt + ) + } + + if (attrs.graphicType === "gradient") { + return ( +
+ ) + } + + return ( + + ) +} + +function ResizeHandleBtn({ + handle, + onPointerDown, +}: { + handle: ResizeHandle + onPointerDown: (handle: ResizeHandle, event: React.PointerEvent) => void +}) { + const posClass: Record = { + nw: "left-0 top-0 cursor-nwse-resize", + n: "left-1/2 top-0 -translate-x-1/2 cursor-ns-resize", + ne: "right-0 top-0 cursor-nesw-resize", + e: "right-0 top-1/2 -translate-y-1/2 cursor-ew-resize", + se: "right-0 bottom-0 cursor-nwse-resize", + s: "left-1/2 bottom-0 -translate-x-1/2 cursor-ns-resize", + sw: "left-0 bottom-0 cursor-nesw-resize", + w: "left-0 top-1/2 -translate-y-1/2 cursor-ew-resize", + } + + return ( + { + event.preventDefault() + event.stopPropagation() + onPointerDown(handle, event) + }} + /> + ) +} + +function RotationHandle({ + onPointerDown, +}: { + onPointerDown: (event: React.PointerEvent) => void +}) { + return ( + { + event.preventDefault() + event.stopPropagation() + onPointerDown(event) + }} + > + + + + ) +} + +function DocsGraphicNodeViewInner({ + node, + updateAttributes, + selected, + editor, + getPos, + extension, +}: NodeViewProps) { + const attrs = parseGraphicAttrs(node.attrs as Record) + const layout = computeGraphicLayoutStyle(attrs) + const editable = editor.isEditable + const inline = extension.name === "docsInlineGraphic" + const replaceInputRef = useRef(null) + const dragRef = useRef<{ startX: number; startY: number; originX: number; originY: number } | null>( + null + ) + const pendingDragRef = useRef<{ + pointerId: number + startX: number + startY: number + originX: number + originY: number + host: HTMLElement + } | null>(null) + const resizeRef = useRef<{ + handle: ResizeHandle + startX: number + startY: number + width: number + height: number + originX: number + originY: number + lockAspect: boolean + } | null>(null) + const rotateRef = useRef<{ + centerX: number + centerY: number + startAngle: number + originRotation: number + } | null>(null) + const [interacting, setInteracting] = useState(false) + const [trackingPointer, setTrackingPointer] = useState(false) + const [cropMode, setCropMode] = useState(false) + + useEffect(() => { + if (!selected) setCropMode(false) + }, [selected]) + + const onDragPointerDown = useCallback( + (event: React.PointerEvent) => { + if (!editable || !selected || cropMode) return + if ((event.target as HTMLElement).closest(".docs-graphic-handle, .docs-graphic-rotate-handle, .docs-graphic-crop")) { + return + } + + event.stopPropagation() + + const host = event.currentTarget as HTMLElement + pendingDragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: attrs.x, + originY: attrs.y, + host, + } + setTrackingPointer(true) + }, + [attrs.x, attrs.y, cropMode, editable, selected] + ) + + const onResizeStart = useCallback( + (handle: ResizeHandle, event: React.PointerEvent) => { + if (!editable || cropMode) return + event.preventDefault() + ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId) + resizeRef.current = { + handle, + startX: event.clientX, + startY: event.clientY, + width: attrs.width, + height: attrs.height, + originX: attrs.x, + originY: attrs.y, + lockAspect: event.shiftKey, + } + setInteracting(true) + }, + [attrs.height, attrs.width, attrs.x, attrs.y, cropMode, editable] + ) + + const onRotateStart = useCallback( + (event: React.PointerEvent) => { + if (!editable || cropMode) return + event.preventDefault() + ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId) + const host = (event.currentTarget as HTMLElement).closest(".docs-graphic") as HTMLElement + const rect = host.getBoundingClientRect() + const centerX = rect.left + rect.width / 2 + const centerY = rect.top + rect.height / 2 + const startAngle = Math.atan2(event.clientY - centerY, event.clientX - centerX) + rotateRef.current = { + centerX, + centerY, + startAngle, + originRotation: attrs.rotationDeg, + } + setInteracting(true) + }, + [attrs.rotationDeg, cropMode, editable] + ) + + useEffect(() => { + if (!interacting && !trackingPointer) return + + const onMove = (event: PointerEvent) => { + if (pendingDragRef.current && !dragRef.current) { + const pending = pendingDragRef.current + if (pending.pointerId !== event.pointerId) return + const dx = event.clientX - pending.startX + const dy = event.clientY - pending.startY + if (Math.hypot(dx, dy) < 4) return + + const prose = pending.host.closest(".ProseMirror") as HTMLElement | null + let originX = pending.originX + let originY = pending.originY + + const needsAbsolute = + attrs.placement !== "absolute" && + attrs.wrap !== "behind" && + attrs.wrap !== "in-front" + + if (needsAbsolute && prose) { + const proseRect = prose.getBoundingClientRect() + const rect = pending.host.getBoundingClientRect() + originX = Math.round(rect.left - proseRect.left) + originY = Math.round(rect.top - proseRect.top) + updateAttributes({ + placement: "absolute", + x: originX, + y: originY, + }) + } + + pending.host.setPointerCapture(event.pointerId) + dragRef.current = { + startX: pending.startX, + startY: pending.startY, + originX, + originY, + } + pendingDragRef.current = null + setInteracting(true) + } + if (dragRef.current) { + const { startX, startY, originX, originY } = dragRef.current + updateAttributes({ + x: Math.round(originX + (event.clientX - startX)), + y: Math.round(originY + (event.clientY - startY)), + }) + } + if (resizeRef.current) { + const { handle, startX, startY, width, height, originX, originY, lockAspect } = + resizeRef.current + const next = resizeWithHandle( + handle, + width, + height, + event.clientX - startX, + event.clientY - startY, + 24, + lockAspect || event.shiftKey + ) + const patch: Partial = { + width: next.width, + height: next.height, + } + if ( + attrs.placement === "absolute" || + attrs.wrap === "behind" || + attrs.wrap === "in-front" + ) { + patch.x = Math.round(originX + next.xOffset) + patch.y = Math.round(originY + next.yOffset) + } + updateAttributes(patch) + } + if (rotateRef.current) { + const { centerX, centerY, startAngle, originRotation } = rotateRef.current + const angle = Math.atan2(event.clientY - centerY, event.clientX - centerX) + const delta = ((angle - startAngle) * 180) / Math.PI + updateAttributes({ rotationDeg: Math.round(originRotation + delta) }) + } + } + + const onUp = () => { + dragRef.current = null + pendingDragRef.current = null + resizeRef.current = null + rotateRef.current = null + setInteracting(false) + setTrackingPointer(false) + } + + window.addEventListener("pointermove", onMove) + window.addEventListener("pointerup", onUp) + return () => { + window.removeEventListener("pointermove", onMove) + window.removeEventListener("pointerup", onUp) + } + }, [attrs.placement, attrs.wrap, interacting, trackingPointer, updateAttributes]) + + const selectNode = useCallback(() => { + const pos = getPos() + if (typeof pos !== "number") return + editor.chain().focus().setNodeSelection(pos).run() + }, [editor, getPos]) + + const replaceImage = useCallback(() => { + replaceInputRef.current?.click() + }, []) + + const graphicBody = ( +
{ + selectNode() + onDragPointerDown(event) + }} + onDoubleClick={(event) => { + if (!editable || attrs.graphicType !== "image") return + event.preventDefault() + event.stopPropagation() + setCropMode(true) + }} + > +
+ +
+ {selected && editable && cropMode && attrs.graphicType === "image" ? ( + setCropMode(false)} + /> + ) : null} + {selected && editable && !cropMode ? ( + <> + + + {RESIZE_HANDLES.map((handle) => ( + + ))} + + ) : null} + { + const file = event.target.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = () => { + updateAttributes({ src: reader.result as string }) + } + reader.readAsDataURL(file) + event.target.value = "" + }} + /> +
+ ) + + return ( + + {selected && editable ? ( + setCropMode(true)} + onReplaceImage={replaceImage} + > + {graphicBody} + + ) : ( + graphicBody + )} + + ) +} + +export const DocsGraphicNodeView = memo(DocsGraphicNodeViewInner) diff --git a/components/drive/richtext/docs-graphic-options-panel.tsx b/components/drive/richtext/docs-graphic-options-panel.tsx new file mode 100644 index 0000000..bed3c8f --- /dev/null +++ b/components/drive/richtext/docs-graphic-options-panel.tsx @@ -0,0 +1,257 @@ +"use client" + +import type { Editor } from "@tiptap/react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + buildGradientCss, + DOCS_GRAPHIC_PLACEMENT_LABELS, + DOCS_GRAPHIC_WRAP_LABELS, + parseGraphicAttrs, + type DocsGraphicPlacement, + type DocsGraphicWrap, +} from "@/lib/drive/docs-graphic-types" +import { readGraphicToolbarActive } from "@/components/drive/richtext/docs-graphic-toolbar-menu" + +export function DocsGraphicOptionsPanel({ + editor, + disabled, + open, + onOpenChange, +}: { + editor: Editor | null + disabled?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void +}) { + if (!editor || !readGraphicToolbarActive(editor)) return null + + const name = editor.isActive("docsInlineGraphic") ? "docsInlineGraphic" : "docsGraphic" + const attrs = parseGraphicAttrs(editor.getAttributes(name) as Record) + + const update = (patch: Record) => { + editor.chain().focus().updateDocsGraphic(patch).run() + } + + const numField = ( + label: string, + key: "width" | "height" | "x" | "y" | "rotationDeg", + step = 1 + ) => ( +
+ + update({ [key]: Number(e.target.value) || 0 })} + /> +
+ ) + + return ( + + + + + +

+ {attrs.graphicType === "image" + ? "Options image" + : attrs.graphicType === "shape" + ? "Options forme" + : "Options dégradé"} +

+ +
+ {numField("Largeur (px)", "width")} + {numField("Hauteur (px)", "height")} + {numField("X", "x")} + {numField("Y", "y")} + {numField("Rotation (°)", "rotationDeg")} +
+ +
+ + +
+ +
+ + +
+ + {attrs.graphicType === "shape" ? ( +
+
+ + update({ fill: e.target.value })} + /> +
+
+ + update({ stroke: e.target.value })} + /> +
+
+ + update({ strokeWidth: Number(e.target.value) || 0 })} + /> +
+
+ ) : null} + + {attrs.graphicType === "gradient" ? ( +
+
+ + { + const gradientColor1 = e.target.value + update({ + gradientColor1, + gradientCss: buildGradientCss( + attrs.gradientAngle, + gradientColor1, + attrs.gradientColor2 + ), + }) + }} + /> +
+
+ + { + const gradientColor2 = e.target.value + update({ + gradientColor2, + gradientCss: buildGradientCss( + attrs.gradientAngle, + attrs.gradientColor1, + gradientColor2 + ), + }) + }} + /> +
+
+ + { + const gradientAngle = Number(e.target.value) || 0 + update({ + gradientAngle, + gradientCss: buildGradientCss( + gradientAngle, + attrs.gradientColor1, + attrs.gradientColor2 + ), + }) + }} + /> +
+
+ ) : null} + + {attrs.graphicType === "image" ? ( + + ) : null} +
+
+ ) +} diff --git a/components/drive/richtext/docs-graphic-toolbar-menu.tsx b/components/drive/richtext/docs-graphic-toolbar-menu.tsx new file mode 100644 index 0000000..dcfeff4 --- /dev/null +++ b/components/drive/richtext/docs-graphic-toolbar-menu.tsx @@ -0,0 +1,222 @@ +"use client" + +import { useRef } from "react" +import type { Editor } from "@tiptap/react" +import { Icon } from "@iconify/react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Button } from "@/components/ui/button" +import { buildInsertGraphicAttrs } from "@/lib/drive/extensions/docs-graphic" +import { + DOCS_GRAPHIC_PLACEMENT_LABELS, + DOCS_GRAPHIC_WRAP_LABELS, + type DocsGraphicFloatSide, + type DocsGraphicPlacement, + type DocsGraphicWrap, + parseGraphicAttrs, +} from "@/lib/drive/docs-graphic-types" + +function readSelectedGraphicAttrs(editor: Editor) { + if (editor.isActive("docsGraphic")) { + return parseGraphicAttrs(editor.getAttributes("docsGraphic") as Record) + } + if (editor.isActive("docsInlineGraphic")) { + return parseGraphicAttrs(editor.getAttributes("docsInlineGraphic") as Record) + } + return null +} + +export function DocsGraphicInsertMenu({ + editor, + disabled, +}: { + editor: Editor | null + disabled?: boolean +}) { + const imageInputRef = useRef(null) + + if (!editor) return null + + const insertImage = (file: File, options?: { wrap?: DocsGraphicWrap; placement?: DocsGraphicPlacement }) => { + const reader = new FileReader() + reader.onload = () => { + const src = reader.result as string + editor + .chain() + .focus() + .insertDocsGraphic( + buildInsertGraphicAttrs("image", { + src, + wrap: options?.wrap ?? "square", + placement: options?.placement ?? "block", + width: 280, + height: 180, + }) + ) + .run() + } + reader.readAsDataURL(file) + } + + return ( + <> + + + + + + imageInputRef.current?.click()}> + Image… + + + Forme + + {(["rect", "ellipse", "line", "arrow"] as const).map((shapeType) => ( + + editor + .chain() + .focus() + .insertDocsGraphic(buildInsertGraphicAttrs("shape", { shapeType })) + .run() + } + > + {shapeType === "rect" + ? "Rectangle" + : shapeType === "ellipse" + ? "Ellipse" + : shapeType === "line" + ? "Ligne" + : "Flèche"} + + ))} + + + + editor + .chain() + .focus() + .insertDocsGraphic(buildInsertGraphicAttrs("gradient")) + .run() + } + > + Dégradé + + + + { + const file = event.target.files?.[0] + if (file) insertImage(file) + event.target.value = "" + }} + /> + + ) +} + +export function DocsGraphicLayoutMenu({ + editor, + disabled, +}: { + editor: Editor | null + disabled?: boolean +}) { + if (!editor) return null + const attrs = readSelectedGraphicAttrs(editor) + if (!attrs) return null + + const applyWrap = (wrap: DocsGraphicWrap) => { + editor.chain().focus().setDocsGraphicWrap(wrap).run() + } + const applyPlacement = (placement: DocsGraphicPlacement) => { + editor.chain().focus().setDocsGraphicPlacement(placement).run() + } + const applyFloatSide = (floatSide: DocsGraphicFloatSide) => { + editor.chain().focus().setDocsGraphicFloatSide(floatSide).run() + } + + return ( + + + + + + + Habillage texte + + {(Object.keys(DOCS_GRAPHIC_WRAP_LABELS) as DocsGraphicWrap[]).map((wrap) => ( + applyWrap(wrap)}> + {DOCS_GRAPHIC_WRAP_LABELS[wrap]} + {attrs.wrap === wrap ? " ✓" : ""} + + ))} + + + + + Placement + + {(Object.keys(DOCS_GRAPHIC_PLACEMENT_LABELS) as DocsGraphicPlacement[]).map( + (placement) => ( + applyPlacement(placement)}> + {DOCS_GRAPHIC_PLACEMENT_LABELS[placement]} + {attrs.placement === placement ? " ✓" : ""} + + ) + )} + + + + + Côté du flottement + + {(["left", "right", "center"] as const).map((side) => ( + applyFloatSide(side)}> + {side === "left" ? "Gauche" : side === "right" ? "Droite" : "Centre"} + {attrs.floatSide === side ? " ✓" : ""} + + ))} + + + + + ) +} + +export function readGraphicToolbarActive(editor: Editor | null): boolean { + if (!editor) return false + return editor.isActive("docsGraphic") || editor.isActive("docsInlineGraphic") +} diff --git a/components/drive/richtext/docs-header-footer-dialogs.tsx b/components/drive/richtext/docs-header-footer-dialogs.tsx new file mode 100644 index 0000000..b0144a0 --- /dev/null +++ b/components/drive/richtext/docs-header-footer-dialogs.tsx @@ -0,0 +1,208 @@ +"use client" + +import { useState } from "react" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" +import type { DocPageNumberSettings, DocPageSetup } from "@/lib/drive/doc-page-setup" +import { cmToMm, mmToCm } from "@/lib/drive/doc-page-setup" + +export function DocsHeaderFooterFormatDialog({ + open, + onOpenChange, + pageSetup, + onApply, +}: { + open: boolean + onOpenChange: (open: boolean) => void + pageSetup: DocPageSetup | null + onApply: (patch: Partial) => void +}) { + const headerCm = mmToCm(pageSetup?.headerMarginMm ?? pageSetup?.marginsMm.top ?? 20) + const footerCm = mmToCm(pageSetup?.footerMarginMm ?? pageSetup?.marginsMm.bottom ?? 20) + const [headerMarginCm, setHeaderMarginCm] = useState(headerCm) + const [footerMarginCm, setFooterMarginCm] = useState(footerCm) + const [differentFirst, setDifferentFirst] = useState( + pageSetup?.headerFooterDifferentFirstPage ?? false + ) + const [differentOddEven, setDifferentOddEven] = useState( + pageSetup?.headerFooterDifferentOddEven ?? false + ) + + return ( + + + + En-têtes et pieds de page + +
+
+

Marges

+
+
+ + setHeaderMarginCm(Number(e.target.value) || 0)} + /> +
+
+ + setFooterMarginCm(Number(e.target.value) || 0)} + /> +
+
+
+
+

Mise en page

+
+ + +
+
+
+ + + + +
+
+ ) +} + +export function DocsPageNumbersDialog({ + open, + onOpenChange, + settings, + onApply, +}: { + open: boolean + onOpenChange: (open: boolean) => void + settings: DocPageNumberSettings | null | undefined + onApply: (settings: DocPageNumberSettings) => void +}) { + const [placement, setPlacement] = useState<"header" | "footer">( + settings?.placement ?? "header" + ) + const [startAt, setStartAt] = useState(settings?.startAt ?? 1) + const [showOnFirstPage, setShowOnFirstPage] = useState(settings?.showOnFirstPage ?? true) + + return ( + + + + Numéros de page + +
+
+

Position

+
+ + +
+
+ +
+ + setStartAt(Number(e.target.value) || 0)} + className="w-24" + /> +
+
+ + + + +
+
+ ) +} diff --git a/components/drive/richtext/docs-header-footer-region.tsx b/components/drive/richtext/docs-header-footer-region.tsx new file mode 100644 index 0000000..cdd0e6a --- /dev/null +++ b/components/drive/richtext/docs-header-footer-region.tsx @@ -0,0 +1,481 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { ChevronDown } from "lucide-react" +import type { Editor } from "@tiptap/react" +import type { DocPageLayout, DocPageSetup } from "@/lib/drive/doc-page-setup" +import { pxToMm } from "@/lib/drive/doc-page-setup" +import { + DOCS_HF_CHROME_BAR_PX, + defaultFooterZoneHeightPx, + defaultHeaderZoneHeightPx, + pageFooterGeometry, + pageHeaderGeometry, + resolveRegionForPage, + toggleDifferentFirstPage, + type DocsHeaderFooterRegion, +} from "@/lib/drive/docs-header-footer-layout" +import { DocsRegionEditor } from "@/components/drive/richtext/docs-region-editor" +import { + DocsHeaderFooterFormatDialog, + DocsPageNumbersDialog, +} from "@/components/drive/richtext/docs-header-footer-dialogs" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Checkbox } from "@/components/ui/checkbox" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +export function extractRegionPlainText(content: Record | undefined): string { + if (!content) return "" + const parts: string[] = [] + const walk = (node: unknown) => { + if (!node || typeof node !== "object") return + const record = node as Record + if (typeof record.text === "string") parts.push(record.text) + if (Array.isArray(record.content)) record.content.forEach(walk) + } + walk(content) + return parts.join(" ").trim() +} + +export type { DocsHeaderFooterRegion } + +export type DocsHeaderFooterEditTarget = { + region: DocsHeaderFooterRegion + pageIndex: number +} | null + +function DocsHeaderFooterChrome({ + label, + pageWidth, + barTop, + placement, + showFirstPageCheckbox, + differentFirstPage, + onDifferentFirstPageChange, + onFormatOpen, + onPageNumOpen, + onRemove, +}: { + label: string + pageWidth: number + barTop: number + placement: DocsHeaderFooterRegion + showFirstPageCheckbox: boolean + differentFirstPage: boolean + onDifferentFirstPageChange: (checked: boolean) => void + onFormatOpen: () => void + onPageNumOpen: () => void + onRemove: () => void +}) { + return ( +
+
event.preventDefault()} + > + {label} +
+ {showFirstPageCheckbox ? ( + + ) : null} + + + + + + + {label === "En-tête" + ? "Format de l'en-tête" + : "Format du pied de page"} + + + Numéros de page + + + {label === "En-tête" + ? "Supprimer l'en-tête" + : "Supprimer le pied de page"} + + + +
+
+
+ ) +} + +export function DocsHeaderFooterBand({ + region, + pageLayout, + pageIndex, + pageTop, + pageWidth, + pageHeight, + margins, + editable, + editingTarget, + onStartEdit, + onStopEdit, + onContentChange, + onPageSetupChange, + onRegionEditorReady, + onRegionHeightMeasure, +}: { + region: DocsHeaderFooterRegion + pageLayout: DocPageLayout + pageIndex: number + pageTop: number + pageWidth: number + pageHeight: number + margins: { top: number; right: number; bottom: number; left: number } + editable: boolean + editingTarget: DocsHeaderFooterEditTarget + onStartEdit: (region: DocsHeaderFooterRegion, pageIndex: number) => void + onStopEdit: () => void + onContentChange: ( + region: DocsHeaderFooterRegion, + content: Record, + meta: { pageIndex: number; contentHeightPx: number }, + options?: { immediate?: boolean } + ) => void + onPageSetupChange: (patch: Partial) => void + onRegionEditorReady?: (editor: Editor | null) => void + onRegionHeightMeasure?: (payload: { + region: DocsHeaderFooterRegion + pageIndex: number + heightPx: number + }) => void +}) { + const isHeader = region === "header" + const isEditing = + editingTarget?.region === region && editingTarget.pageIndex === pageIndex + const canEdit = editable + + const regionData = resolveRegionForPage(pageLayout, region, pageIndex) + const differentFirstPage = pageLayout.headerFooterDifferentFirstPage ?? false + + const headerGeom = pageHeaderGeometry(pageLayout, pageTop, pageIndex) + const footerGeom = pageFooterGeometry(pageLayout, pageTop, pageHeight, pageIndex) + + const bandLeft = margins.left + const bandWidth = pageWidth - margins.left - margins.right + + const zoneHeight = isHeader ? headerGeom.zoneHeight : footerGeom.zoneHeight + const minZoneHeight = isHeader + ? defaultHeaderZoneHeightPx(pageLayout) + : defaultFooterZoneHeightPx(pageLayout) + + const [liveHeight, setLiveHeight] = useState(zoneHeight) + const liveHeightRef = useRef(zoneHeight) + liveHeightRef.current = liveHeight + + useEffect(() => { + if (isEditing) return + setLiveHeight((height) => Math.min(height, zoneHeight)) + }, [isEditing, zoneHeight]) + + /** View: content-sized. Edit: at least default header/footer margin band. */ + const contentHeight = isEditing + ? Math.max(minZoneHeight, liveHeight) + : Math.max(20, liveHeight) + + const zoneTop = isHeader + ? headerGeom.zoneTop + : footerGeom.zoneBottom - contentHeight + + const headerChromeTop = headerGeom.zoneTop + contentHeight + const footerChromeTop = + footerGeom.zoneBottom - contentHeight - DOCS_HF_CHROME_BAR_PX + const chromeBarTop = isHeader ? headerChromeTop : footerChromeTop + + const [formatOpen, setFormatOpen] = useState(false) + const [pageNumOpen, setPageNumOpen] = useState(false) + const contentPersistTimer = useRef | null>(null) + const latestContentRef = useRef | null>(null) + + const persistRegionContent = useCallback( + (content: Record, immediate = false) => { + latestContentRef.current = content + onContentChange( + region, + content, + { + pageIndex, + contentHeightPx: Math.max(minZoneHeight, liveHeightRef.current), + }, + immediate ? { immediate: true } : undefined + ) + }, + [minZoneHeight, onContentChange, pageIndex, region] + ) + + const scheduleRegionContentPersist = useCallback( + (content: Record) => { + latestContentRef.current = content + if (contentPersistTimer.current) clearTimeout(contentPersistTimer.current) + contentPersistTimer.current = setTimeout(() => { + persistRegionContent(content) + }, 800) + }, + [persistRegionContent] + ) + + const pageNumber = + pageLayout.pageNumbers?.enabled && + pageLayout.pageNumbers.placement === region && + (pageLayout.pageNumbers.showOnFirstPage || pageIndex > 0) + ? (pageLayout.pageNumbers.startAt ?? 1) + pageIndex + : null + + const handleRemove = useCallback(() => { + onContentChange( + region, + { type: "doc", content: [{ type: "paragraph" }] }, + { pageIndex, contentHeightPx: minZoneHeight }, + { immediate: true } + ) + onPageSetupChange(isHeader ? { header: null, headerFirstPage: null } : { footer: null, footerFirstPage: null }) + onStopEdit() + }, [isHeader, minZoneHeight, onContentChange, onPageSetupChange, onStopEdit, pageIndex, region]) + + const setupPatch: DocPageSetup = { + widthMm: pageLayout.format.widthMm, + heightMm: pageLayout.format.heightMm, + marginsMm: { + top: pxToMm(pageLayout.marginsPx.top), + right: pxToMm(pageLayout.marginsPx.right), + bottom: pxToMm(pageLayout.marginsPx.bottom), + left: pxToMm(pageLayout.marginsPx.left), + }, + headerMarginMm: pageLayout.headerMarginMm, + footerMarginMm: pageLayout.footerMarginMm, + headerFooterDifferentFirstPage: differentFirstPage, + headerFooterDifferentOddEven: pageLayout.headerFooterDifferentOddEven, + pageNumbers: pageLayout.pageNumbers, + header: pageLayout.header, + footer: pageLayout.footer, + headerFirstPage: pageLayout.headerFirstPage, + footerFirstPage: pageLayout.footerFirstPage, + } + + const hasContent = + regionData?.content && extractRegionPlainText(regionData.content).length > 0 + + const regionLabel = isHeader ? "En-tête" : "Pied de page" + + const handleDoubleClick = (event: React.MouseEvent) => { + if (!canEdit) return + event.preventDefault() + event.stopPropagation() + onStartEdit(region, pageIndex) + } + + const persistHeight = useCallback( + (heightPx: number) => { + const measured = Math.max(20, heightPx) + setLiveHeight(measured) + onRegionHeightMeasure?.({ region, pageIndex, heightPx: measured }) + }, + [onRegionHeightMeasure, pageIndex, region] + ) + + useEffect(() => { + return () => { + if (contentPersistTimer.current) clearTimeout(contentPersistTimer.current) + } + }, []) + + const wasEditingRef = useRef(false) + useEffect(() => { + if (wasEditingRef.current && !isEditing) { + if (contentPersistTimer.current) { + clearTimeout(contentPersistTimer.current) + contentPersistTimer.current = null + } + persistRegionContent( + (latestContentRef.current ?? + regionData?.content ?? + ({ type: "doc", content: [{ type: "paragraph" }] } as Record)), + true + ) + } + wasEditingRef.current = isEditing + }, [isEditing, persistRegionContent, regionData?.content]) + + return ( + <> + {isEditing ? ( + <> +
+ + {isHeader ? ( +
+ ) : ( +
+ )} + + { + onPageSetupChange(toggleDifferentFirstPage(setupPatch, checked)) + }} + onFormatOpen={() => setFormatOpen(true)} + onPageNumOpen={() => setPageNumOpen(true)} + onRemove={handleRemove} + /> + + ) : null} + + {!isEditing && !hasContent && canEdit ? ( +
+ ) : null} + + {isEditing || hasContent ? ( +
+ { + requestAnimationFrame(() => { + const active = document.activeElement + if ( + active?.closest(".docs-hf-chrome") || + active?.closest('[role="dialog"]') || + active?.closest("[data-radix-popper-content-wrapper]") + ) { + return + } + onStopEdit() + }) + } + : undefined + } + onEditorReady={isEditing ? onRegionEditorReady : undefined} + onContentHeightChange={persistHeight} + /> + + {pageNumber != null && !isEditing ? ( +
+ {pageNumber} +
+ ) : null} +
+ ) : null} + + {isEditing ? ( + <> + + onPageSetupChange({ pageNumbers })} + /> + + ) : null} + + ) +} diff --git a/components/drive/richtext/docs-horizontal-ruler.tsx b/components/drive/richtext/docs-horizontal-ruler.tsx new file mode 100644 index 0000000..9616f42 --- /dev/null +++ b/components/drive/richtext/docs-horizontal-ruler.tsx @@ -0,0 +1,139 @@ +"use client" + +import { memo, useRef } from "react" +import type { DocPageLayout } from "@/lib/drive/doc-page-setup" +import { DOCS_HORIZONTAL_RULER_HEIGHT_PX } from "@/lib/drive/docs-page-layout-constants" +import { docsPageLengthToScreen } from "@/lib/drive/docs-ruler-scale" +import type { DocsParagraphIndents } from "@/lib/drive/use-docs-ruler-sync" +import { buildHorizontalRulerTicks } from "@/lib/drive/docs-ruler-math" +import type { DocsRulerMarginSide } from "@/lib/drive/docs-ruler-margin-math" +import { + DocsRulerDraggableHandle, + DocsRulerFirstLineMarker, + DocsRulerTriangleMarker, + useRulerPointerDrag, +} from "@/components/drive/richtext/docs-ruler-markers" + +function DocsHorizontalRulerInner({ + pageLayout, + scale, + indents, + editable, + onMarginDragStart, + onMarginDrag, + onMarginDragEnd, +}: { + pageLayout: DocPageLayout + scale: number + indents: DocsParagraphIndents + editable?: boolean + onMarginDragStart?: (side: DocsRulerMarginSide) => void + onMarginDrag?: (side: DocsRulerMarginSide, pagePx: number, clientX: number, clientY: number) => void + onMarginDragEnd?: () => void +}) { + const rulerRef = useRef(null) + const pageWidth = pageLayout.widthPx + const margins = pageLayout.marginsPx + const scaledWidth = docsPageLengthToScreen(pageWidth, scale) + const ticks = buildHorizontalRulerTicks(pageWidth, pageLayout.format.id) + const s = (px: number) => docsPageLengthToScreen(px, scale) + + const leftDrag = useRulerPointerDrag({ + rulerRef, + axis: "horizontal", + disabled: !editable, + onDrag: (pagePx, clientX, clientY) => onMarginDrag?.("left", pagePx, clientX, clientY), + onDragEnd: () => onMarginDragEnd?.(), + }) + + const rightDrag = useRulerPointerDrag({ + rulerRef, + axis: "horizontal", + disabled: !editable, + onDrag: (pagePx, clientX, clientY) => onMarginDrag?.("right", pagePx, clientX, clientY), + onDragEnd: () => onMarginDragEnd?.(), + }) + + const wrapMarginDown = + (side: DocsRulerMarginSide, handler: (e: React.PointerEvent) => void) => + (event: React.PointerEvent) => { + onMarginDragStart?.(side) + handler(event) + } + + return ( +
+
+
+ + {ticks.map((tick, index) => ( +
+ ))} + + {ticks + .filter((tick) => tick.major && tick.label != null) + .map((tick) => ( + + {tick.label} + + ))} + + + + + + + + + + + + {Math.abs(indents.firstLinePx - indents.leftPx) > 1 ? ( + + ) : null} +
+ ) +} + +export const DocsHorizontalRuler = memo(DocsHorizontalRulerInner) diff --git a/components/drive/richtext/docs-menubar.tsx b/components/drive/richtext/docs-menubar.tsx index b1b9cfc..ddb81c9 100644 --- a/components/drive/richtext/docs-menubar.tsx +++ b/components/drive/richtext/docs-menubar.tsx @@ -2,31 +2,23 @@ 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 { DocsViewMenu, type DocsViewMenuActions, type DocsViewMenuState } from "@/components/drive/richtext/docs-view-menu" import { DOCS_MENUBAR_CONTENT_PROPS } from "@/components/drive/richtext/docs-menubar-props" import { Menubar, MenubarContent, MenubarItem, MenubarMenu, - MenubarSeparator, MenubarTrigger, } from "@/components/ui/menubar" -import { PAGE_FORMATS, type PageFormatId } from "@/lib/drive/page-formats" import { cn } from "@/lib/utils" -const OTHER_MENU_LABELS = [ - "Affichage", - "Insertion", - "Format", - "Outils", - "Aide", -] as const +const OTHER_MENU_LABELS = ["Insertion", "Format", "Outils", "Aide"] as const export function DocsMenubar({ - pageFormatId, - onPageFormatChange, - zoom, - onZoomChange, + viewMenuActions, + viewMenuState, + viewMenuDisabled, fileMenuActions, fileMenuDisabled, editMenuActions, @@ -34,10 +26,9 @@ export function DocsMenubar({ editMenuDisabled, className, }: { - pageFormatId: PageFormatId - onPageFormatChange: (id: PageFormatId) => void - zoom: number - onZoomChange: (zoom: number) => void + viewMenuActions?: DocsViewMenuActions + viewMenuState?: DocsViewMenuState + viewMenuDisabled?: boolean fileMenuActions?: DocsFileMenuActions fileMenuDisabled?: boolean editMenuActions?: DocsEditMenuActions @@ -82,54 +73,24 @@ export function DocsMenubar({ )} - {OTHER_MENU_LABELS.map((label) => { - if (label === "Affichage") { - return ( - - {label} - - - Mode (bientôt) - - -
- Taille de page -
- {PAGE_FORMATS.map((format) => ( - onPageFormatChange(format.id)} - className={cn(pageFormatId === format.id && "bg-accent")} - > - {format.label} - - {format.widthMm} × {format.heightMm} mm - - - ))} - -
- Zoom -
- {[50, 75, 100, 125, 150, 200].map((value) => ( - onZoomChange(value)} - className={cn(zoom === value && "bg-accent")} - > - {value}% - - ))} -
-
- ) - } + {viewMenuActions && viewMenuState ? ( + + ) : ( + + Affichage + + + Bientôt disponible + + + + )} - return ( + {OTHER_MENU_LABELS.map((label) => ( {label} - ) - })} + ))} ) } diff --git a/components/drive/richtext/docs-page-view.tsx b/components/drive/richtext/docs-page-view.tsx index ea4daef..23ab814 100644 --- a/components/drive/richtext/docs-page-view.tsx +++ b/components/drive/richtext/docs-page-view.tsx @@ -1,24 +1,62 @@ "use client" -import { memo, useEffect, useRef, useState } from "react" +import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react" import { EditorContent, type Editor } from "@tiptap/react" -import type { DocPageLayout } from "@/lib/drive/doc-page-setup" +import type { DocPageLayout, DocPageSetup } from "@/lib/drive/doc-page-setup" +import { + DocsHeaderFooterBand, + type DocsHeaderFooterEditTarget, + type DocsHeaderFooterRegion, +} from "@/components/drive/richtext/docs-header-footer-region" +import { DocsBodyMarginMasks } from "@/components/drive/richtext/docs-body-margin-masks" +import { + DOCS_CANVAS_PADDING_TOP_NARROW_PX, + DOCS_CANVAS_PADDING_Y_PX, + DOCS_PAGE_GAP_PX, +} from "@/lib/drive/docs-page-layout-constants" +import { + effectiveMarginsPx, +} from "@/lib/drive/docs-header-footer-layout" +import { + computePageCount, + computePageMetrics, + computeProseMinHeight, + computeStackHeight, +} from "@/lib/drive/docs-page-metrics" +import { docsPageLengthToScreen, docsZoomToScale } from "@/lib/drive/docs-ruler-scale" import { cn } from "@/lib/utils" import { focusEditorAtPointer } from "@/lib/drive/focus-editor-at-pointer" +import { applyPageFlowLayout, computeSimulatedLayoutHeight, readPageFlowMetrics } from "@/lib/drive/extensions/docs-page-flow-decoration" -const PAGE_GAP_PX = 12 - -/** Actual block layout height — ignores CSS min-height on ProseMirror (page stack). */ +/** Total layout height inside ProseMirror (blocks + flow spacers). */ function measureProseContentHeight(prose: HTMLElement): number { - if (prose.childElementCount === 0) { - return 0 + const metrics = readPageFlowMetrics(prose) + if (!metrics) return 0 + + const blocks: Array<{ height: number }> = [] + for (const child of prose.children) { + const el = child as HTMLElement + if (el.classList.contains("docs-page-flow-spacer")) continue + const style = getComputedStyle(el) + const marginBottom = parseFloat(style.marginBottom) || 0 + blocks.push({ height: el.offsetHeight + marginBottom }) } + + if (blocks.length === 0) return 0 + + const simulated = computeSimulatedLayoutHeight( + blocks, + metrics.bodyAreaH, + metrics.interPageSpacer + ) + let maxBottom = 0 for (const child of prose.children) { const el = child as HTMLElement maxBottom = Math.max(maxBottom, el.offsetTop + el.offsetHeight) } - return maxBottom + + return Math.max(simulated, maxBottom) } function DocsPageViewInner({ @@ -26,85 +64,217 @@ function DocsPageViewInner({ pageLayout, zoom, editable, + showLayout, + showNonPrintableChars, + editorMode, + canvasRef: canvasRefProp, onPageCountChange, + onNarrowViewportChange, + onCanvasHeightChange, + onRegionContentChange, + onPageSetupChange, + onRegionEditorChange, }: { editor: Editor pageLayout: DocPageLayout zoom: number editable: boolean + showLayout: boolean + showRuler: boolean + showNonPrintableChars: boolean + editorMode: "edit" | "suggest" | "view" + canvasRef?: RefObject onPageCountChange?: (count: number) => void + onNarrowViewportChange?: (narrow: boolean) => void + onCanvasHeightChange?: (height: number) => void + onRegionContentChange?: ( + region: DocsHeaderFooterRegion, + content: Record, + meta: { pageIndex: number; contentHeightPx: number } + ) => void + onPageSetupChange?: (patch: Partial) => void + onRegionEditorChange?: (editor: Editor | null) => void }) { const pageWidth = pageLayout.widthPx const pageHeight = pageLayout.heightPx const margins = pageLayout.marginsPx - const canvasRef = useRef(null) - const contentRef = useRef(null) + const [pageCount, setPageCount] = useState(1) const [narrowViewport, setNarrowViewport] = useState(false) + const [editingTarget, setEditingTarget] = useState(null) + const [pageRegionHeights, setPageRegionHeights] = useState< + Record + >({}) + + const handleRegionHeightMeasure = useCallback( + (payload: { + region: DocsHeaderFooterRegion + pageIndex: number + heightPx: number + }) => { + const key = `${payload.region}-${payload.pageIndex}` + setPageRegionHeights((prev) => { + if (prev[key] === payload.heightPx) return prev + return { ...prev, [key]: payload.heightPx } + }) + }, + [] + ) + + const measuredRegionHeights = useMemo(() => { + let header = 0 + let footer = 0 + for (const [key, heightPx] of Object.entries(pageRegionHeights)) { + if (key.startsWith("header-")) header = Math.max(header, heightPx) + if (key.startsWith("footer-")) footer = Math.max(footer, heightPx) + } + return { + ...(header > 0 ? { header } : {}), + ...(footer > 0 ? { footer } : {}), + } + }, [pageRegionHeights]) + + const effectiveMargins = useMemo( + () => + effectiveMarginsPx( + pageLayout, + null, + editingTarget ? undefined : measuredRegionHeights + ), + [pageLayout, editingTarget, measuredRegionHeights] + ) + const metrics = useMemo( + () => computePageMetrics({ ...pageLayout, effectiveMarginsPx: effectiveMargins }), + [pageLayout, effectiveMargins] + ) + + const localCanvasRef = useRef(null) + const canvasRef = canvasRefProp ?? localCanvasRef + const contentRef = useRef(null) const onPageCountChangeRef = useRef(onPageCountChange) onPageCountChangeRef.current = onPageCountChange - const scale = zoom / 100 - const scaledWidth = pageWidth * scale + const scale = docsZoomToScale(zoom) + const scaledWidth = docsPageLengthToScreen(pageWidth, scale) + + const stopRegionEdit = useCallback(() => { + setEditingTarget(null) + onRegionEditorChange?.(null) + }, [onRegionEditorChange]) + + const startRegionEdit = useCallback( + (region: DocsHeaderFooterRegion, pageIndex: number) => { + setEditingTarget({ region, pageIndex }) + }, + [] + ) + + const handleRegionEditorReady = useCallback( + (editor: Editor | null) => { + onRegionEditorChange?.(editor) + }, + [onRegionEditorChange] + ) + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape" && editingTarget) { + event.preventDefault() + stopRegionEdit() + } + } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, [editingTarget, stopRegionEdit]) useEffect(() => { const canvas = canvasRef.current if (!canvas) return - const syncViewport = () => { - setNarrowViewport(canvas.clientWidth < scaledWidth) + const narrow = canvas.clientWidth < scaledWidth + setNarrowViewport(narrow) + onNarrowViewportChange?.(narrow) + onCanvasHeightChange?.(canvas.clientHeight) } - syncViewport() const ro = new ResizeObserver(syncViewport) ro.observe(canvas) return () => ro.disconnect() - }, [scaledWidth]) + }, [onCanvasHeightChange, onNarrowViewportChange, scaledWidth, canvasRef]) useEffect(() => { + if (!showLayout) return const surface = contentRef.current if (!surface) return - let rafId = 0 + let debounceId: ReturnType | null = null + let cancelled = false - const measure = () => { + const measurePageCount = () => { const prose = surface.querySelector(".ProseMirror") as HTMLElement | null if (!prose) return - const contentHeight = measureProseContentHeight(prose) - const paddedHeight = margins.top + margins.bottom + contentHeight - const count = Math.max(1, Math.ceil(paddedHeight / pageHeight)) + const count = computePageCount(contentHeight, metrics) setPageCount((prev) => (prev === count ? prev : count)) } - const scheduleMeasure = () => { - if (rafId) cancelAnimationFrame(rafId) - rafId = requestAnimationFrame(measure) + const runLayoutPasses = (passesLeft: number) => { + if (cancelled || editor.isDestroyed) return + requestAnimationFrame(() => { + if (cancelled || editor.isDestroyed) return + const changed = applyPageFlowLayout(editor) + if (changed && passesLeft > 1) { + runLayoutPasses(passesLeft - 1) + return + } + measurePageCount() + }) } - scheduleMeasure() - const prose = surface.querySelector(".ProseMirror") as HTMLElement | null - const ro = prose ? new ResizeObserver(scheduleMeasure) : null - if (prose && ro) ro.observe(prose) + let flushPending = false + const scheduleLayout = () => { + if (!flushPending) { + flushPending = true + requestAnimationFrame(() => { + flushPending = false + runLayoutPasses(2) + }) + } + if (debounceId) clearTimeout(debounceId) + debounceId = setTimeout(() => { + debounceId = null + runLayoutPasses(2) + }, 32) + } - const onTransaction = () => scheduleMeasure() + scheduleLayout() + + const onTransaction = ({ transaction }: { transaction: { docChanged: boolean } }) => { + if (!transaction.docChanged) return + scheduleLayout() + } editor.on("transaction", onTransaction) return () => { - if (rafId) cancelAnimationFrame(rafId) - ro?.disconnect() + cancelled = true + if (debounceId) clearTimeout(debounceId) editor.off("transaction", onTransaction) } - }, [margins.bottom, margins.top, pageHeight, editor]) + }, [editor, metrics, pageRegionHeights, showLayout]) useEffect(() => { onPageCountChangeRef.current?.(pageCount) }, [pageCount]) - const stackHeight = pageCount * pageHeight + (pageCount - 1) * PAGE_GAP_PX - const innerMinHeight = Math.max(pageHeight - margins.top - margins.bottom, stackHeight - margins.top - margins.bottom) - const scaledHeight = stackHeight * scale - const verticalPadding = narrowViewport ? 32 : 64 + const stackHeight = computeStackHeight(pageCount, pageHeight) + const proseMinHeight = computeProseMinHeight(pageCount, metrics) + + const scaledHeight = docsPageLengthToScreen(stackHeight, scale) + const verticalPaddingTop = narrowViewport + ? DOCS_CANVAS_PADDING_TOP_NARROW_PX + : DOCS_CANVAS_PADDING_Y_PX + const verticalPaddingBottom = DOCS_CANVAS_PADDING_Y_PX + const textAreaBorderCss = pageLayout.textAreaBorderCss const sheetBorderCss = pageLayout.sheetBorderCss const pageBackground = pageLayout.pageColor @@ -161,72 +331,83 @@ function DocsPageViewInner({ ) + const bodyDimmed = editingTarget != null + return ( -
+
- {Array.from({ length: pageCount }, (_, index) => ( -
- {renderPageBackground(index)} -
- ))} + {showLayout + ? Array.from({ length: pageCount }, (_, index) => { + const pageTop = index * (pageHeight + DOCS_PAGE_GAP_PX) + return ( +
+ {renderPageBackground(index)} +
+ ) + }) + : null} - {textAreaBorderCss + {showLayout && textAreaBorderCss ? Array.from({ length: pageCount }, (_, index) => (
+ ) : null} + + {showLayout + ? Array.from({ length: pageCount }, (_, index) => { + const pageTop = index * (pageHeight + DOCS_PAGE_GAP_PX) + return ( +
+ + onRegionContentChange?.(region, content, meta) + } + onPageSetupChange={(patch) => onPageSetupChange?.(patch)} + onRegionHeightMeasure={handleRegionHeightMeasure} + onRegionEditorReady={ + editingTarget?.region === "header" && + editingTarget.pageIndex === index + ? handleRegionEditorReady + : undefined + } + /> + + onRegionContentChange?.(region, content, meta) + } + onPageSetupChange={(patch) => onPageSetupChange?.(patch)} + onRegionHeightMeasure={handleRegionHeightMeasure} + onRegionEditorReady={ + editingTarget?.region === "footer" && + editingTarget.pageIndex === index + ? handleRegionEditorReady + : undefined + } + /> +
+ ) + }) + : null} + + {bodyDimmed ? ( +
+ ) : null} +
{ - if (!editable) return + if (bodyDimmed) { + const target = event.target as HTMLElement + if ( + !target.closest(".docs-hf-band") && + !target.closest(".docs-hf-chrome") + ) { + stopRegionEdit() + } + return + } + if (!editable || editingTarget) return const target = event.target as HTMLElement if (target.closest(".ProseMirror")) return event.preventDefault() @@ -270,12 +549,16 @@ export const DocsPageView = memo(DocsPageViewInner) export function DocsStatusBar({ pageLayout, pageCount, + currentPage = 1, className, }: { pageLayout: DocPageLayout pageCount: number + currentPage?: number className?: string }) { + const pageLabel = Math.min(Math.max(1, currentPage), Math.max(1, pageCount)) + return (
- Page 1 sur {pageCount} + + Page {pageLabel} sur {pageCount} + {pageLayout.format.label} ({pageLayout.format.widthMm} × {pageLayout.format.heightMm} mm) diff --git a/components/drive/richtext/docs-region-editor.tsx b/components/drive/richtext/docs-region-editor.tsx new file mode 100644 index 0000000..33fdc59 --- /dev/null +++ b/components/drive/richtext/docs-region-editor.tsx @@ -0,0 +1,135 @@ +"use client" + +import { useEffect, useRef } from "react" +import { useEditor, EditorContent, type Editor } from "@tiptap/react" +import { + buildRegionEditorExtensions, + RICHTEXT_REGION_EDITOR_CLASS, +} from "@/lib/drive/richtext-extensions" +import { cn } from "@/lib/utils" + +function regionEditorProseStyle(maxHeightPx?: number, minHeightPx?: number): string | undefined { + const parts: string[] = [] + if (minHeightPx != null) parts.push(`min-height:${minHeightPx}px`) + if (maxHeightPx != null) { + parts.push(`max-height:${maxHeightPx}px`, "overflow-y:auto") + } + return parts.length > 0 ? parts.join(";") : undefined +} + +export function DocsRegionEditor({ + content, + editable, + placeholder, + className, + maxHeightPx, + minHeightPx, + onUpdate, + onBlur, + onEditorReady, + onContentHeightChange, + autoFocus, +}: { + content: Record | undefined + editable: boolean + placeholder?: string + className?: string + /** When set, content scrolls inside. Omit to grow with content. */ + maxHeightPx?: number + /** Minimum editable band height (header/footer margin zone). */ + minHeightPx?: number + onUpdate?: (json: Record) => void + onBlur?: () => void + onEditorReady?: (editor: Editor | null) => void + onContentHeightChange?: (height: number) => void + autoFocus?: boolean +}) { + const syncingRef = useRef(false) + const rootRef = useRef(null) + const proseStyle = regionEditorProseStyle(maxHeightPx, minHeightPx) + + const editor = useEditor({ + immediatelyRender: false, + editable, + extensions: buildRegionEditorExtensions(placeholder), + content: content ?? { type: "doc", content: [{ type: "paragraph" }] }, + editorProps: { + attributes: { + class: RICHTEXT_REGION_EDITOR_CLASS, + ...(proseStyle ? { style: proseStyle } : {}), + }, + }, + onUpdate: ({ editor: ed }) => { + if (syncingRef.current) return + onUpdate?.(ed.getJSON() as Record) + }, + onBlur: () => onBlur?.(), + }) + + useEffect(() => { + onEditorReady?.(editor) + return () => onEditorReady?.(null) + }, [editor, onEditorReady]) + + useEffect(() => { + if (!editor || !content) return + syncingRef.current = true + editor.commands.setContent(content, { emitUpdate: false }) + syncingRef.current = false + }, [content, editor]) + + useEffect(() => { + editor?.setEditable(editable) + if (editable && autoFocus) { + requestAnimationFrame(() => editor?.commands.focus("end")) + } + }, [autoFocus, editable, editor]) + + useEffect(() => { + if (!editor) return + const style = regionEditorProseStyle(maxHeightPx, minHeightPx) + editor.setOptions({ + editorProps: { + attributes: { + class: RICHTEXT_REGION_EDITOR_CLASS, + ...(style ? { style } : {}), + }, + }, + }) + }, [editor, maxHeightPx, minHeightPx]) + + useEffect(() => { + const root = rootRef.current + if (!root || !onContentHeightChange) return + const prose = root.querySelector(".ProseMirror") as HTMLElement | null + if (!prose) return + + const report = () => { + requestAnimationFrame(() => onContentHeightChange(prose.offsetHeight)) + } + report() + const ro = new ResizeObserver(report) + ro.observe(prose) + return () => ro.disconnect() + }, [editor, onContentHeightChange]) + + if (!editor) return null + + return ( +
+ +
+ ) +} diff --git a/components/drive/richtext/docs-ruler-markers.tsx b/components/drive/richtext/docs-ruler-markers.tsx new file mode 100644 index 0000000..0085020 --- /dev/null +++ b/components/drive/richtext/docs-ruler-markers.tsx @@ -0,0 +1,216 @@ +"use client" + +import { memo, useRef, type ReactNode } from "react" +import { createPortal } from "react-dom" +import { cn } from "@/lib/utils" + +export type DocsRulerDragTooltipState = { + label: string + x: number + y: number +} | null + +export const DocsRulerMarginDragTooltip = memo(function DocsRulerMarginDragTooltip({ + tooltip, +}: { + tooltip: DocsRulerDragTooltipState +}) { + if (!tooltip || typeof document === "undefined") return null + + return createPortal( +
+ {tooltip.label} +
, + document.body + ) +}) + +/** Blue downward triangle (margin / left indent). */ +export const DocsRulerTriangleMarker = memo(function DocsRulerTriangleMarker({ + left, + className, +}: { + left: number + className?: string +}) { + return ( +
+ + + +
+ ) +}) + +/** Blue rectangle on horizontal ruler (first-line indent). */ +export const DocsRulerFirstLineMarker = memo(function DocsRulerFirstLineMarker({ + left, +}: { + left: number +}) { + return ( +
+ ) +}) + +/** Blue upward triangle on vertical ruler (top margin). */ +export const DocsRulerUpTriangleMarker = memo(function DocsRulerUpTriangleMarker({ + top, +}: { + top: number +}) { + return ( +
+ + + +
+ ) +}) + +/** Blue downward triangle on vertical ruler (bottom margin). */ +export const DocsRulerDownTriangleMarker = memo(function DocsRulerDownTriangleMarker({ + top, +}: { + top: number +}) { + return ( +
+ + + +
+ ) +}) + +export const DocsRulerDraggableHandle = memo(function DocsRulerDraggableHandle({ + style, + className, + disabled, + axis, + ariaLabel, + onPointerDown, + children, +}: { + style: React.CSSProperties + className?: string + disabled?: boolean + axis: "horizontal" | "vertical" + ariaLabel: string + onPointerDown: (event: React.PointerEvent) => void + children: ReactNode +}) { + return ( +
+
+ {children} +
+
+ ) +}) + +export function useRulerPointerDrag({ + rulerRef, + axis, + disabled, + onDrag, + onDragEnd, +}: { + rulerRef: React.RefObject + axis: "horizontal" | "vertical" + disabled?: boolean + onDrag: (pagePx: number, clientX: number, clientY: number) => void + onDragEnd: () => void +}) { + const draggingRef = useRef(false) + + const onPointerDown = (event: React.PointerEvent) => { + if (disabled) return + event.preventDefault() + event.stopPropagation() + + const handle = event.currentTarget + handle.setPointerCapture(event.pointerId) + draggingRef.current = true + document.body.style.userSelect = "none" + document.body.style.cursor = axis === "horizontal" ? "ew-resize" : "ns-resize" + + const readPagePx = (clientX: number, clientY: number) => { + const ruler = rulerRef.current + if (!ruler) return null + const rect = ruler.getBoundingClientRect() + const scaleAttr = ruler.dataset.docsRulerScale + const scale = scaleAttr ? Number.parseFloat(scaleAttr) : 1 + if (!Number.isFinite(scale) || scale <= 0) return null + return axis === "horizontal" + ? (clientX - rect.left) / scale + : (clientY - rect.top) / scale + } + + const move = (clientX: number, clientY: number) => { + const pagePx = readPagePx(clientX, clientY) + if (pagePx != null) onDrag(pagePx, clientX, clientY) + } + + move(event.clientX, event.clientY) + + const onMove = (ev: PointerEvent) => move(ev.clientX, ev.clientY) + const onUp = (ev: PointerEvent) => { + draggingRef.current = false + document.body.style.userSelect = "" + document.body.style.cursor = "" + if (handle.hasPointerCapture(ev.pointerId)) { + handle.releasePointerCapture(ev.pointerId) + } + window.removeEventListener("pointermove", onMove) + window.removeEventListener("pointerup", onUp) + window.removeEventListener("pointercancel", onUp) + onDragEnd() + } + + window.addEventListener("pointermove", onMove) + window.addEventListener("pointerup", onUp) + window.addEventListener("pointercancel", onUp) + } + + return { onPointerDown, draggingRef } +} diff --git a/components/drive/richtext/docs-rulers-chrome.tsx b/components/drive/richtext/docs-rulers-chrome.tsx new file mode 100644 index 0000000..c52e02c --- /dev/null +++ b/components/drive/richtext/docs-rulers-chrome.tsx @@ -0,0 +1,137 @@ +"use client" + +import type { RefObject } from "react" +import { ListTree } from "lucide-react" +import { DocsHorizontalRuler } from "@/components/drive/richtext/docs-horizontal-ruler" +import { DocsVerticalRuler } from "@/components/drive/richtext/docs-vertical-ruler" +import type { DocPageLayout } from "@/lib/drive/doc-page-setup" +import type { DocsRulerMarginSide } from "@/lib/drive/docs-ruler-margin-math" +import { + DOCS_HORIZONTAL_RULER_HEIGHT_PX, + DOCS_VERTICAL_RULER_WIDTH_PX, +} from "@/lib/drive/docs-page-layout-constants" +import { docsPageLengthToScreen } from "@/lib/drive/docs-ruler-scale" +import type { DocsRulerSyncState } from "@/lib/drive/use-docs-ruler-sync" +import { cn } from "@/lib/utils" + +export function DocsRulerToolbarRow({ + pageLayout, + scale, + rulerSync, + rulerTrackRef, + outlineExpanded, + onToggleOutline, + editable, + onMarginDragStart, + onMarginDrag, + onMarginDragEnd, +}: { + pageLayout: DocPageLayout + scale: number + rulerSync: DocsRulerSyncState + rulerTrackRef: RefObject + outlineExpanded?: boolean + onToggleOutline?: () => void + editable?: boolean + onMarginDragStart?: (side: DocsRulerMarginSide) => void + onMarginDrag?: (side: DocsRulerMarginSide, pagePx: number, clientX: number, clientY: number) => void + onMarginDragEnd?: () => void +}) { + const scaledPageWidth = docsPageLengthToScreen(pageLayout.widthPx, scale) + + return ( +
+
+ +
+ +
+
+ +
+
+
+ ) +} + +export function DocsRulersLeftRail({ + pageLayout, + scale, + rulerSync, + editable, + onMarginDragStart, + onMarginDrag, + onMarginDragEnd, +}: { + pageLayout: DocPageLayout + scale: number + rulerSync: DocsRulerSyncState + editable?: boolean + onMarginDragStart?: (side: DocsRulerMarginSide) => void + onMarginDrag?: (side: DocsRulerMarginSide, pagePx: number, clientX: number, clientY: number) => void + onMarginDragEnd?: () => void +}) { + return ( +
+
+ +
+
+ ) +} diff --git a/components/drive/richtext/docs-toolbar.tsx b/components/drive/richtext/docs-toolbar.tsx index 4bbf449..49dcdf7 100644 --- a/components/drive/richtext/docs-toolbar.tsx +++ b/components/drive/richtext/docs-toolbar.tsx @@ -1,6 +1,6 @@ "use client" -import { memo, useCallback, useMemo, useRef, useState } from "react" +import { memo, useCallback, useMemo, useState } from "react" import { Icon } from "@iconify/react" import type { Editor } from "@tiptap/react" import { @@ -11,7 +11,6 @@ import { ChevronDown, ChevronUp, Bold, - Image as ImageIcon, Italic, Link2, List, @@ -55,6 +54,12 @@ import { DOCS_FONT_FAMILIES, type DocsFontFamilyName, } from "@/lib/drive/docs-font-family" +import { + DocsGraphicInsertMenu, + DocsGraphicLayoutMenu, + readGraphicToolbarActive, +} from "@/components/drive/richtext/docs-graphic-toolbar-menu" +import { DocsGraphicOptionsPanel } from "@/components/drive/richtext/docs-graphic-options-panel" import { useDocsToolbarState } from "@/lib/drive/use-docs-toolbar-state" import { cn } from "@/lib/utils" @@ -125,6 +130,7 @@ function DocsToolbarInner({ showChromeToggle, chromeCollapsed, onToggleChromeCollapsed, + embedded, }: { editor: Editor | null disabled?: boolean @@ -135,24 +141,13 @@ function DocsToolbarInner({ showChromeToggle?: boolean chromeCollapsed?: boolean onToggleChromeCollapsed?: () => void + /** Rendered inside DocsEditorWorkspace shell (no outer docs-toolbar-shell). */ + embedded?: boolean }) { - const imageInputRef = useRef(null) const [linkOpen, setLinkOpen] = useState(false) const [linkUrl, setLinkUrl] = useState("") const toolbarState = useDocsToolbarState(editor) - - const insertImage = useCallback( - (file: File) => { - if (!editor) return - const reader = new FileReader() - reader.onload = () => { - const src = reader.result as string - editor.chain().focus().setImage({ src }).run() - } - reader.readAsDataURL(file) - }, - [editor] - ) + const graphicSelected = readGraphicToolbarActive(editor) const applyLink = useCallback(() => { if (!editor) return @@ -473,16 +468,18 @@ function DocsToolbarInner({ ), }, { - id: "insert-image", + id: "insert-graphic", sepAfter: true, node: ( - imageInputRef.current?.click()} - > - - + <> + + {graphicSelected ? ( + <> + + + + ) : null} + ), }, { @@ -597,6 +594,7 @@ function DocsToolbarInner({ onZoomChange, spellcheck, onToggleSpellcheck, + graphicSelected, linkOpen, linkUrl, applyLink, @@ -611,13 +609,7 @@ function DocsToolbarInner({ const visibleSegments = segments.slice(0, visibleCount) const overflowSegments = segments.slice(visibleCount) - return ( -
+ const toolbarRow = (
) : null} - - { - const file = e.target.files?.[0] - if (file) insertImage(file) - e.target.value = "" - }} - />
+ ) + + if (embedded) return toolbarRow + + return ( +
+ {toolbarRow}
) } diff --git a/components/drive/richtext/docs-vertical-ruler.tsx b/components/drive/richtext/docs-vertical-ruler.tsx new file mode 100644 index 0000000..f4ec1db --- /dev/null +++ b/components/drive/richtext/docs-vertical-ruler.tsx @@ -0,0 +1,130 @@ +"use client" + +import { memo, useRef } from "react" +import type { DocPageLayout } from "@/lib/drive/doc-page-setup" +import { DOCS_VERTICAL_RULER_WIDTH_PX } from "@/lib/drive/docs-page-layout-constants" +import { docsPageLengthToScreen } from "@/lib/drive/docs-ruler-scale" +import { buildVerticalRulerTicks } from "@/lib/drive/docs-ruler-math" +import type { DocsRulerMarginSide } from "@/lib/drive/docs-ruler-margin-math" +import { + DocsRulerDownTriangleMarker, + DocsRulerDraggableHandle, + DocsRulerUpTriangleMarker, + useRulerPointerDrag, +} from "@/components/drive/richtext/docs-ruler-markers" + +function DocsVerticalRulerInner({ + pageLayout, + scale, + editable, + onMarginDragStart, + onMarginDrag, + onMarginDragEnd, +}: { + pageLayout: DocPageLayout + scale: number + editable?: boolean + onMarginDragStart?: (side: DocsRulerMarginSide) => void + onMarginDrag?: (side: DocsRulerMarginSide, pagePx: number, clientX: number, clientY: number) => void + onMarginDragEnd?: () => void +}) { + const rulerRef = useRef(null) + const pageHeight = pageLayout.heightPx + const margins = pageLayout.marginsPx + const scaledHeight = docsPageLengthToScreen(pageHeight, scale) + const ticks = buildVerticalRulerTicks(pageHeight, margins.top, pageLayout.format.id) + const s = (px: number) => docsPageLengthToScreen(px, scale) + + const topDrag = useRulerPointerDrag({ + rulerRef, + axis: "vertical", + disabled: !editable, + onDrag: (pagePx, clientX, clientY) => onMarginDrag?.("top", pagePx, clientX, clientY), + onDragEnd: () => onMarginDragEnd?.(), + }) + + const bottomDrag = useRulerPointerDrag({ + rulerRef, + axis: "vertical", + disabled: !editable, + onDrag: (pagePx, clientX, clientY) => onMarginDrag?.("bottom", pagePx, clientX, clientY), + onDragEnd: () => onMarginDragEnd?.(), + }) + + const wrapMarginDown = + (side: DocsRulerMarginSide, handler: (e: React.PointerEvent) => void) => + (event: React.PointerEvent) => { + onMarginDragStart?.(side) + handler(event) + } + + return ( +
+
+
+ + {ticks.map((tick, index) => ( +
+ ))} + + {ticks + .filter((tick) => tick.major && tick.label != null) + .map((tick) => ( + + {tick.label} + + ))} + + + + + + + + +
+ ) +} + +export const DocsVerticalRuler = memo(DocsVerticalRulerInner) diff --git a/components/drive/richtext/docs-view-menu.tsx b/components/drive/richtext/docs-view-menu.tsx new file mode 100644 index 0000000..19d2f16 --- /dev/null +++ b/components/drive/richtext/docs-view-menu.tsx @@ -0,0 +1,291 @@ +"use client" + +import type { ReactNode } from "react" +import { + AlignHorizontalSpaceAround, + Check, + Eye, + ListTree, + Maximize2, + MessageSquare, + MessageSquarePlus, + Pencil, +} from "lucide-react" +import { + MenubarCheckboxItem, + MenubarContent, + MenubarItem, + MenubarMenu, + MenubarSeparator, + MenubarSubContent, + MenubarSubTrigger, + MenubarTrigger, +} from "@/components/ui/menubar" +import { + DocsExclusiveMenuSub, + DocsExclusiveMenuSubRoot, +} from "@/components/drive/richtext/docs-exclusive-menu-sub" +import { DocsMenuShortcut } from "@/components/drive/richtext/docs-menu-shortcut" +import { DOCS_MENUBAR_CONTENT_PROPS } from "@/components/drive/richtext/docs-menubar-props" +import type { + DocsCommentsDisplay, + DocsEditorMode, +} from "@/lib/drive/docs-view-settings" +import { cn } from "@/lib/utils" + +export type DocsViewMenuState = { + editorMode: DocsEditorMode + commentsDisplay: DocsCommentsDisplay + showLayout: boolean + showRuler: boolean + showEquationToolbar: boolean + showNonPrintableChars: boolean +} + +export type DocsViewMenuActions = { + onEditorModeChange: (mode: DocsEditorMode) => void + onCommentsDisplayChange: (display: DocsCommentsDisplay) => void + onToggleOutlineSidebar: () => void + onToggleShowLayout: () => void + onToggleShowRuler: () => void + onToggleShowEquationToolbar: () => void + onToggleShowNonPrintableChars: () => void + onFullscreen: () => void +} + +const EDITOR_MODES = [ + { + id: "edit" as const, + label: "Édition", + description: "Modifier le document directement", + icon: Pencil, + }, + { + id: "suggest" as const, + label: "Suggestion", + description: "Suggérer des modifications", + icon: MessageSquarePlus, + }, + { + id: "view" as const, + label: "Affichage", + description: "Lire ou imprimer le document final", + icon: Eye, + }, +] as const + +const COMMENTS_OPTIONS = [ + { id: "hidden" as const, label: "Masquer les commentaires" }, + { id: "collapsed" as const, label: "Réduire les commentaires" }, + { id: "expanded" as const, label: "Développer les commentaires" }, + { id: "all" as const, label: "Afficher tous les commentaires" }, +] as const + +function MenuIcon({ children }: { children: ReactNode }) { + return {children} +} + +function CheckMenuOption({ + checked, + onSelect, + children, + disabled, +}: { + checked: boolean + onSelect: () => void + children: ReactNode + disabled?: boolean +}) { + return ( + + {checked ? : null} + {children} + + ) +} + +function ModeMenuOption({ + icon: Icon, + label, + description, + selected, + onSelect, + disabled, +}: { + icon: typeof Pencil + label: string + description: string + selected: boolean + onSelect: () => void + disabled?: boolean +}) { + return ( + + + + + + {label} + {description} + + + ) +} + +export function DocsViewMenu({ + state, + actions, + disabled, +}: { + state: DocsViewMenuState + actions: DocsViewMenuActions + disabled?: boolean +}) { + return ( + + Affichage + + + + + + + + Mode + + + {EDITOR_MODES.map((mode) => ( + actions.onEditorModeChange(mode.id)} + /> + ))} + + + + + + + + + Commentaires + + + {COMMENTS_OPTIONS.map((option) => ( + actions.onCommentsDisplayChange(option.id)} + > + {option.label} + + ))} + + + + + + + + Développer la barre latérale des onglets et sections + + Ctrl+⌥A Ctrl+⌥H + + + + + + + + + + + Largeur du texte + + + + Bientôt disponible + + + + + + + + actions.onToggleShowLayout()} + > + Afficher la mise en page + + actions.onToggleShowRuler()} + > + Afficher la règle + + actions.onToggleShowEquationToolbar()} + > + Afficher la barre d'outils d'équation + + actions.onToggleShowNonPrintableChars()} + > + Afficher les caractères non imprimables + + + + + + + + + + Plein écran + + + + ) +} diff --git a/components/drive/richtext/use-docs-ruler-margin-drag.ts b/components/drive/richtext/use-docs-ruler-margin-drag.ts new file mode 100644 index 0000000..fcaaf97 --- /dev/null +++ b/components/drive/richtext/use-docs-ruler-margin-drag.ts @@ -0,0 +1,155 @@ +"use client" + +import { useCallback, useMemo, useRef, useState } from "react" +import type { DocPageLayout, DocPageSetup } from "@/lib/drive/doc-page-setup" +import { pxToMm } from "@/lib/drive/doc-page-setup" +import { + clampPageMarginPx, + mergeMarginPreview, + type DocsPageMarginsPx, + type DocsRulerMarginSide, +} from "@/lib/drive/docs-ruler-margin-math" +import { formatMarginDistanceLabel } from "@/lib/drive/docs-ruler-units" +import type { DocsRulerDragTooltipState } from "@/components/drive/richtext/docs-ruler-markers" + +function applyMarginDragPx( + side: DocsRulerMarginSide, + rawValuePx: number, + base: DocsPageMarginsPx, + preview: Partial | null, + pageWidth: number, + pageHeight: number +): { nextPreview: Partial; clamped: number } { + const current = mergeMarginPreview(base, preview) + let nextValue = rawValuePx + if (side === "right") { + nextValue = pageWidth - rawValuePx + } else if (side === "bottom") { + nextValue = pageHeight - rawValuePx + } + const clamped = clampPageMarginPx( + side, + nextValue, + current, + pageWidth, + pageHeight + ) + return { + nextPreview: { ...(preview ?? {}), [side]: clamped }, + clamped, + } +} + +export function useDocsRulerMarginDrag({ + pageLayout, + editable, + onPageSetupChange, +}: { + pageLayout: DocPageLayout + editable: boolean + onPageSetupChange?: ( + patch: Partial, + options?: { immediate?: boolean } + ) => void +}) { + const [previewPx, setPreviewPx] = useState | null>(null) + const [dragTooltip, setDragTooltip] = useState(null) + const dragBaseRef = useRef(pageLayout.marginsPx) + /** Sync preview during drag — window pointer events don't flush React state in time. */ + const previewRef = useRef | null>(null) + const pageLayoutRef = useRef(pageLayout) + pageLayoutRef.current = pageLayout + + const marginsPx = useMemo( + () => mergeMarginPreview(pageLayout.marginsPx, previewPx), + [pageLayout.marginsPx, previewPx] + ) + + const pageLayoutWithMargins = useMemo( + (): DocPageLayout => ({ + ...pageLayout, + marginsPx, + }), + [pageLayout, marginsPx] + ) + + const beginMarginDrag = useCallback( + (_side: DocsRulerMarginSide) => { + if (!editable) return + const layout = pageLayoutRef.current + dragBaseRef.current = mergeMarginPreview(layout.marginsPx, previewRef.current) + previewRef.current = null + }, + [editable] + ) + + const moveMarginDrag = useCallback( + (side: DocsRulerMarginSide, rawValuePx: number, clientX: number, clientY: number) => { + if (!editable) return + + const layout = pageLayoutRef.current + const { nextPreview, clamped } = applyMarginDragPx( + side, + rawValuePx, + dragBaseRef.current, + previewRef.current, + layout.widthPx, + layout.heightPx + ) + + previewRef.current = nextPreview + setPreviewPx(nextPreview) + setDragTooltip({ + label: formatMarginDistanceLabel(clamped, layout.format.id), + x: clientX, + y: clientY, + }) + }, + [editable] + ) + + const endMarginDrag = useCallback(() => { + setDragTooltip(null) + + const preview = previewRef.current + previewRef.current = null + + if (!editable || !preview) { + setPreviewPx(null) + return + } + + const layout = pageLayoutRef.current + const merged = mergeMarginPreview(layout.marginsPx, preview) + setPreviewPx(null) + + onPageSetupChange?.( + { + marginsMm: { + top: pxToMm(merged.top), + right: pxToMm(merged.right), + bottom: pxToMm(merged.bottom), + left: pxToMm(merged.left), + }, + }, + { immediate: true } + ) + }, [editable, onPageSetupChange]) + + const cancelMarginDrag = useCallback(() => { + previewRef.current = null + setPreviewPx(null) + setDragTooltip(null) + }, []) + + return { + marginsPx, + pageLayoutWithMargins, + marginDragActive: previewPx != null, + dragTooltip, + beginMarginDrag, + moveMarginDrag, + endMarginDrag, + cancelMarginDrag, + } +} diff --git a/components/drive/share-dialog.tsx b/components/drive/share-dialog.tsx index bb6d81c..45d1cde 100644 --- a/components/drive/share-dialog.tsx +++ b/components/drive/share-dialog.tsx @@ -1,11 +1,11 @@ "use client" import { useEffect, useMemo, useState } from "react" -import { Icon } from "@iconify/react" import { Building2, Copy, Eye, + Globe, Link2, Loader2, Mail, @@ -30,10 +30,23 @@ import { } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { Textarea } from "@/components/ui/textarea" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" import { useSearchContacts } from "@/lib/api/hooks/use-contact-queries" import { useDriveShares, useDriveMutations } from "@/lib/api/hooks/use-drive-queries" -import type { DriveFileInfo, DriveShare } from "@/lib/api/types" +import type { DriveShare } from "@/lib/api/types" import { displayFileName } from "@/lib/drive/display-file-name" import { FOLDER_SHARE_PERMISSION_OPTIONS, @@ -45,29 +58,25 @@ import { import { NC_SHARE_TYPE, SHARE_SECTION_LABELS, + formatShareDate, groupSharesBySection, shareAccessLabel, shareLinkForCopy, - shareMetaLine, shareOwnerLabel, + sharePermissionsLabel, shareRecipientLabel, - type DriveShareMode, type ShareListSection, } from "@/lib/drive/drive-share-types" -import { DriveFileTypeIcon } from "@/lib/drive/drive-file-icon" import { useDriveUIStore } from "@/lib/stores/drive-ui-store" import { DRIVE_BTN_GHOST, DRIVE_BTN_PRIMARY, - DRIVE_CARD_ACTIVE, - DRIVE_CARD_IDLE, DRIVE_DIALOG_CONTENT, DRIVE_DIALOG_DIVIDER, DRIVE_DIALOG_FOOTER, DRIVE_DIALOG_HEADER, DRIVE_DIALOG_OVERLAY, DRIVE_FIELD_CLASS, - DRIVE_LABEL_CLASS, DRIVE_PANEL_MUTED, DRIVE_TEXT_PRIMARY, DRIVE_TEXT_SECONDARY, @@ -83,59 +92,37 @@ function shareItemLabel(path: string) { } type SharePermissionMode = "viewer" | "editor" | "advanced" +type LinkAccessMode = "public" | "internal" -const PERMISSION_MODE_OPTIONS: { +const PERMISSION_OPTIONS: { id: SharePermissionMode label: string - description: string icon: typeof Eye folderOnly?: boolean }[] = [ - { - id: "viewer", - label: "Lecteur", - description: "Consultation uniquement", - icon: Eye, - }, - { - id: "editor", - label: "Éditeur", - description: "Peut modifier le contenu", - icon: Pencil, - }, - { - id: "advanced", - label: "Avancé", - description: "Définir chaque autorisation", - icon: SlidersHorizontal, - folderOnly: true, - }, + { id: "viewer", label: "Lecteur", icon: Eye }, + { id: "editor", label: "Éditeur", icon: Pencil }, + { id: "advanced", label: "Avancé", icon: SlidersHorizontal, folderOnly: true }, ] -const MODE_OPTIONS: { - id: DriveShareMode +const LINK_ACCESS_OPTIONS: { + id: LinkAccessMode label: string description: string - icon: typeof Link2 + icon: typeof Globe }[] = [ { - id: "contact", - label: "Personne", - description: "Partage direct par e-mail ou compte", - icon: UserRound, + id: "public", + label: "Lien public", + description: "Toute personne disposant du lien peut consulter l'élément.", + icon: Globe, }, { id: "internal", label: "Lien interne", - description: "Réservé aux utilisateurs inscrits connectés", + description: "Réservé aux utilisateurs inscrits et connectés.", icon: Users, }, - { - id: "public", - label: "Lien public", - description: "Accessible à toute personne disposant du lien", - icon: Link2, - }, ] function shareSectionIcon(section: ShareListSection) { @@ -144,6 +131,29 @@ function shareSectionIcon(section: ShareListSection) { return Link2 } +function RoleChip({ permissions }: { permissions: number }) { + return ( + + {sharePermissionsLabel(permissions)} + + ) +} + +function shareTooltipLines(share: DriveShare): string[] { + const lines: string[] = [] + const owner = shareOwnerLabel(share) + if (owner) lines.push(`Propriétaire · ${owner}`) + const created = formatShareDate(share.created_at) + if (created) lines.push(`Créé le ${created}`) + const expires = formatShareDate(share.expires_at) + if (expires) lines.push(`Expire le ${expires}`) + if (share.has_password) lines.push("Protégé par mot de passe") + const url = shareLinkForCopy(share) + if (url) lines.push(url) + if (share.note?.trim()) lines.push(`« ${share.note.trim()} »`) + return lines +} + function ShareEntryRow({ share, onDelete, @@ -155,10 +165,9 @@ function ShareEntryRow({ }) { const url = shareLinkForCopy(share) const recipient = shareRecipientLabel(share) - const owner = shareOwnerLabel(share) - const meta = shareMetaLine(share) const accessLabel = shareAccessLabel(share) const isLink = share.share_type === NC_SHARE_TYPE.LINK + const tooltipLines = shareTooltipLines(share) const copy = async () => { if (!url) return @@ -170,74 +179,74 @@ function ShareEntryRow({ } } - const primaryLine = recipient ?? (url && isLink ? url : accessLabel) + const primaryLabel = isLink ? accessLabel : (recipient ?? accessLabel) return ( -
-
-
-
- - {accessLabel} - - {share.has_password ? ( - - - Mot de passe - - ) : null} + + +
0 && "cursor-default" + )} + > +
+ {isLink ? ( + + ) : share.share_type === NC_SHARE_TYPE.GROUP ? ( + + ) : ( + + )}
-

- {primaryLine} -

+
+

{primaryLabel}

+
- {url && isLink && recipient ? ( -

{url}

+ {share.has_password ? ( + ) : null} - {owner ? ( -

- Propriétaire · {owner} -

- ) : null} + -

{meta}

- - {share.note?.trim() ? ( -

- « {share.note.trim()} » -

- ) : null} -
- -
- {url ? ( +
+ {url ? ( + + ) : null} - ) : null} - +
-
-
+ + {tooltipLines.length > 0 ? ( + + {tooltipLines.map((line) => ( +

+ {line} +

+ ))} +
+ ) : null} + ) } @@ -257,182 +266,167 @@ function ActiveSharesPanel({ onDeleteShare: (shareId: string) => void }) { const grouped = useMemo(() => groupSharesBySection(shares), [shares]) - const sectionOrder: ShareListSection[] = ["links", "people", "groups"] + const sectionOrder: ShareListSection[] = ["people", "groups", "links"] const hasShares = shares.length > 0 + if (loading) { + return ( +
+ + Chargement… +
+ ) + } + + if (error) { + return ( +
+

Impossible de charger les partages.

+ +
+ ) + } + + if (!hasShares) return null + return ( -
+
-

Accès existants

- {!loading ? ( - - ) : null} +

Utilisateurs avec accès

+
- {loading ? ( -
- - Chargement des partages… -
- ) : error ? ( -
-

Impossible de charger les partages existants.

- -
- ) : !hasShares ? ( -

- Aucun partage actif pour cet élément. Créez un lien ou invitez une personne ci-dessus. -

- ) : ( -
- {sectionOrder.map((section) => { - const items = grouped[section] - if (items.length === 0) return null - const SectionIcon = shareSectionIcon(section) - return ( -
-

- - {SHARE_SECTION_LABELS[section]} - ({items.length}) -

-
- {items.map((share) => ( - onDeleteShare(share.id)} - /> - ))} -
-
- ) - })} -
- )} +
+ {sectionOrder.map((section) => { + const items = grouped[section] + if (items.length === 0) return null + const SectionIcon = shareSectionIcon(section) + return ( +
+

+ + {SHARE_SECTION_LABELS[section]} +

+ {items.map((share) => ( + onDeleteShare(share.id)} + /> + ))} +
+ ) + })} +
) } -function SharePermissionsPanel({ - isFolder, - permissionMode, +function AdvancedPermissionsPanel({ folderPermissions, - onPermissionModeChange, onFolderPermissionChange, }: { - isFolder: boolean - permissionMode: SharePermissionMode folderPermissions: FolderSharePermissions - onPermissionModeChange: (mode: SharePermissionMode) => void onFolderPermissionChange: (id: FolderSharePermissionId, checked: boolean) => void }) { const advancedPermissionBits = folderPermissionsToBitmask(folderPermissions) - const modeOptions = PERMISSION_MODE_OPTIONS.filter((option) => isFolder || !option.folderOnly) return ( -
-
- {modeOptions.map((option) => { - const IconComponent = option.icon - const selected = permissionMode === option.id - return ( - - ) - })} -
- - {isFolder && permissionMode === "advanced" ? ( -
- {FOLDER_SHARE_PERMISSION_OPTIONS.map((option) => { - const checked = folderPermissions[option.id] - const checkboxId = `drive-share-perm-${option.id}` - return ( -
- - onFolderPermissionChange(option.id, value === true) - } - /> - -
- ) - })} - {!folderPermissions.viewContent && folderPermissions.addFiles ? ( -

- Dépôt uniquement : les visiteurs pourront ajouter des fichiers sans voir le contenu - existant du dossier. -

- ) : null} - {advancedPermissionBits === 0 ? ( -

- Sélectionnez au moins une autorisation. -

- ) : null} -
+
+ {FOLDER_SHARE_PERMISSION_OPTIONS.map((option) => { + const checkboxId = `drive-share-perm-${option.id}` + return ( +
+ onFolderPermissionChange(option.id, value === true)} + /> + +
+ ) + })} + {!folderPermissions.viewContent && folderPermissions.addFiles ? ( +

+ Dépôt uniquement : les visiteurs pourront ajouter des fichiers sans voir le contenu existant. +

+ ) : null} + {advancedPermissionBits === 0 ? ( +

+ Sélectionnez au moins une autorisation. +

) : null}
) } +function PermissionSelect({ + value, + isFolder, + onChange, + className, +}: { + value: SharePermissionMode + isFolder: boolean + onChange: (mode: SharePermissionMode) => void + className?: string +}) { + const options = PERMISSION_OPTIONS.filter((o) => isFolder || !o.folderOnly) + const selected = options.find((o) => o.id === value) ?? options[0] + const SelectedIcon = selected.icon + + return ( + + ) +} + export function ShareDialog() { const path = useDriveUIStore((s) => s.sharePath) const shareItemType = useDriveUIStore((s) => s.shareItemType) const setSharePath = useDriveUIStore((s) => s.setSharePath) - const [mode, setMode] = useState("public") + const [linkAccessMode, setLinkAccessMode] = useState("public") const [permissionMode, setPermissionMode] = useState("viewer") const [folderPermissions, setFolderPermissions] = useState( () => folderPermissionsFromRole("viewer") @@ -453,25 +447,14 @@ export function ShareDialog() { const itemLabel = useMemo(() => (path ? shareItemLabel(path) : ""), [path]) const isFolder = shareItemType === "directory" - const selectedMode = MODE_OPTIONS.find((m) => m.id === mode) ?? MODE_OPTIONS[0] - - const filePreview = useMemo((): DriveFileInfo | null => { - if (!path) return null - return { - path, - name: itemLabel, - type: shareItemType ?? "file", - size: 0, - mime_type: "", - last_modified: "", - etag: "", - is_favorite: false, - } - }, [path, itemLabel, shareItemType]) + const selectedLinkAccess = LINK_ACCESS_OPTIONS.find((o) => o.id === linkAccessMode) ?? LINK_ACCESS_OPTIONS[0] + const LinkAccessIcon = selectedLinkAccess.icon + const showContactExtras = contactEmail.trim().length > 0 + const hasValidContactEmail = contactEmail.trim().includes("@") useEffect(() => { if (path) { - setMode("public") + setLinkAccessMode("public") setPermissionMode("viewer") setFolderPermissions(folderPermissionsFromRole("viewer")) setContactEmail("") @@ -483,7 +466,7 @@ export function ShareDialog() { useEffect(() => { const email = contactEmail.trim().toLowerCase() - if (mode !== "contact" || !email.includes("@")) { + if (!email.includes("@")) { setRecipientRegistered(null) return } @@ -493,12 +476,12 @@ export function ShareDialog() { .catch(() => setRecipientRegistered(null)) }, 350) return () => window.clearTimeout(timer) - }, [contactEmail, mode, lookupRecipientEmail]) + }, [contactEmail, lookupRecipientEmail]) const advancedPermissionBits = folderPermissionsToBitmask(folderPermissions) - const canCreateShare = - (mode !== "contact" || contactEmail.trim().includes("@")) && - (!isFolder || permissionMode !== "advanced" || advancedPermissionBits > 0) + const canCreateLink = + !isFolder || permissionMode !== "advanced" || advancedPermissionBits > 0 + const canShareWithContact = hasValidContactEmail && canCreateLink const setFolderPermission = (id: FolderSharePermissionId, checked: boolean) => { setFolderPermissions((prev) => ({ ...prev, [id]: checked })) @@ -521,44 +504,49 @@ export function ShareDialog() { return base } - const onShare = async () => { - if (!path || !canCreateShare) return + const onCreateLink = async () => { + if (!path || !canCreateLink) return try { - const payload = sharePayload() const share = await createShare.mutateAsync({ - ...payload, - mode, - ...(mode === "contact" - ? { - share_with: contactEmail.trim().toLowerCase(), - note: contactNote.trim() || undefined, - send_mail: true, - } - : {}), + ...sharePayload(), + mode: linkAccessMode, + }) + const link = shareLinkForCopy(share) + if (link) { + await navigator.clipboard.writeText(link) + toast.success( + linkAccessMode === "internal" + ? "Lien interne copié" + : "Lien public copié" + ) + } else { + toast.success("Partage créé") + } + void refetchShares() + } catch { + toast.error("Partage impossible") + } + } + + const onShareWithContact = async () => { + if (!path || !canShareWithContact) return + try { + const share = await createShare.mutateAsync({ + ...sharePayload(), + mode: "contact", + share_with: contactEmail.trim().toLowerCase(), + note: contactNote.trim() || undefined, + send_mail: true, }) - if (mode === "contact") { - if (share.access_mode === "user" || share.share_type === NC_SHARE_TYPE.USER) { - toast.success("Partagé — visible dans « Partagés avec moi » du destinataire") - } else { - toast.success("Invitation envoyée par e-mail avec un lien public") - } - setContactEmail("") - setContactNote("") - setContactQuery("") + if (share.access_mode === "user" || share.share_type === NC_SHARE_TYPE.USER) { + toast.success("Partagé — visible dans « Partagés avec moi »") } else { - const link = shareLinkForCopy(share) - if (link) { - await navigator.clipboard.writeText(link) - toast.success( - mode === "internal" - ? "Lien interne copié dans le presse-papiers" - : "Lien public copié dans le presse-papiers" - ) - } else { - toast.success("Partage créé") - } + toast.success("Invitation envoyée par e-mail") } + setContactEmail("") + setContactNote("") + setContactQuery("") void refetchShares() } catch { toast.error("Partage impossible") @@ -580,96 +568,27 @@ export function ShareDialog() { const existingShares = data?.shares ?? [] - const actionLabel = - mode === "contact" - ? "Partager" - : mode === "internal" - ? "Créer le lien interne" - : "Créer le lien public" - return ( - !open && close()}> - - -
- {filePreview ? ( -
- -
- ) : ( -
- -
- )} -
- - {isFolder ? "Partager le dossier" : "Partager le fichier"} - - - {itemLabel} - -
-
-
+ + !open && close()}> + + + + Partager « {itemLabel} » + + + {isFolder ? "Partager le dossier" : "Partager le fichier"} {itemLabel} + + -
-
- {MODE_OPTIONS.map((option) => { - const IconComponent = option.icon - const selected = mode === option.id - return ( - - ) - })} -
- -
-
-

{selectedMode.label}

-

- {mode === "public" - ? `Toute personne disposant du lien pourra accéder à ${isFolder ? "ce dossier" : "ce fichier"} selon le rôle choisi.` - : mode === "internal" - ? `Seuls les utilisateurs inscrits et connectés pourront ouvrir ce lien vers ${isFolder ? "ce dossier" : "ce fichier"}.` - : "Si le destinataire possède un compte, le fichier apparaît dans ses « Partagés avec moi ». Sinon, il reçoit un e-mail avec un lien public."} -

-
- - {mode === "contact" ? ( -
-
- +
+ {/* Ajouter des personnes */} +
+
+
{contactQuery.length >= 2 && contactResults.length > 0 ? ( -
+
{contactResults.slice(0, 6).map((c) => c.email ? (
-
- + {showContactExtras ? ( + + ) : null} +
+ + {showContactExtras ? ( +