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

This commit is contained in:
R3D347HR4Y 2026-06-10 12:48:27 +02:00
parent 8e420509a8
commit 2a7c153748
62 changed files with 8273 additions and 781 deletions

View File

@ -999,12 +999,35 @@ html.dark :where(
} }
html.dark :where([data-slot='dialog-content'], [data-slot='sheet-content']) 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; background-color: var(--mail-surface-muted) !important;
border-color: var(--mail-border) !important; border-color: var(--mail-border) !important;
color: var(--mail-text) !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 { html.dark :where([data-slot='dialog-content'], [data-slot='sheet-content']) fieldset.border {
border-color: var(--mail-border) !important; border-color: var(--mail-border) !important;
} }

View File

@ -7,17 +7,21 @@ import { HocuspocusProvider } from "@hocuspocus/provider"
import * as Y from "yjs" import * as Y from "yjs"
import { toast } from "sonner" import { toast } from "sonner"
import { DocsChrome } from "@/components/drive/richtext/docs-chrome" 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 { DocsToolbar } from "@/components/drive/richtext/docs-toolbar"
import { buildRichTextExtensions, RICHTEXT_EDITOR_CLASS } from "@/lib/drive/richtext-extensions" import { buildRichTextExtensions, RICHTEXT_EDITOR_CLASS } from "@/lib/drive/richtext-extensions"
import type { RichTextSaveStatus, RichTextSessionResponse } from "@/lib/drive/richtext-types" import type { RichTextSaveStatus, RichTextSessionResponse } from "@/lib/drive/richtext-types"
import type { DriveShare, DriveFileInfo } from "@/lib/api/types" import type { DriveShare, DriveFileInfo } from "@/lib/api/types"
import { useDocsEditMenu } from "@/lib/drive/use-docs-edit-menu" import { useDocsEditMenu } from "@/lib/drive/use-docs-edit-menu"
import { useDocsFileMenu } from "@/lib/drive/use-docs-file-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 { 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 { useCollabPresence } from "@/lib/drive/use-collab-presence"
import { apiClient } from "@/lib/api/client" import { apiClient } from "@/lib/api/client"
import { driveDownloadApiPath } from "@/lib/api/drive-download" import { driveDownloadApiPath } from "@/lib/api/drive-download"
import { buildRegionPatch } from "@/lib/drive/docs-header-footer-layout"
import { import {
buildPageSetupForFormat, buildPageSetupForFormat,
buildPageSetupFromDraft, buildPageSetupFromDraft,
@ -27,11 +31,14 @@ import {
import { readUserPageSetupDefaults } from "@/lib/drive/docs-page-defaults" import { readUserPageSetupDefaults } from "@/lib/drive/docs-page-defaults"
import { isEmptyTipTapDoc } from "@/lib/drive/richtext-content" import { isEmptyTipTapDoc } from "@/lib/drive/richtext-content"
import { importFileToTipTap } from "@/lib/drive/richtext-import" import { importFileToTipTap } from "@/lib/drive/richtext-import"
import { migrateBase64ImagesInContent } from "@/lib/drive/docs-graphic-assets"
import { isUltidocPath } from "@/lib/drive/richtext-formats" import { isUltidocPath } from "@/lib/drive/richtext-formats"
import { cn } from "@/lib/utils"
const SAVE_DEBOUNCE_MS = 2000 const SAVE_DEBOUNCE_MS = 2000
/** Align with Hocuspocus store debounce + buffer */ /** Align with Hocuspocus store debounce + buffer */
const COLLAB_SAVE_IDLE_MS = 2000 const COLLAB_SAVE_IDLE_MS = 2000
const PAGE_SETUP_DEBOUNCE_MS = 1500
export type RichTextDocsChromeProps = { export type RichTextDocsChromeProps = {
title: string title: string
@ -94,13 +101,32 @@ export function RichTextDocumentEditor({
) )
const [saveStatus, setSaveStatus] = useState<RichTextSaveStatus>("idle") const [saveStatus, setSaveStatus] = useState<RichTextSaveStatus>("idle")
const [pageCount, setPageCount] = useState(1) const [pageCount, setPageCount] = useState(1)
const [currentPage, setCurrentPage] = useState(1)
const [regionEditor, setRegionEditor] = useState<import("@tiptap/react").Editor | null>(null)
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null) const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const saveIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null) const saveIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pageSetupTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const documentPageSetupRef = useRef<DocPageSetup | null>(session.pageSetup ?? null)
const saveStatusRef = useRef<RichTextSaveStatus>("idle") const saveStatusRef = useRef<RichTextSaveStatus>("idle")
const reloadAfterReimportRef = useRef(false) const reloadAfterReimportRef = useRef(false)
const purgeReimportingRef = useRef(false)
const providerRef = useRef<HocuspocusProvider | null>(null)
const { settings, setPageFormatId, setZoom, toggleSpellcheck, toggleChromeCollapsed } = const {
useDocsViewSettings() settings,
setPageFormatId,
setZoom,
toggleSpellcheck,
toggleChromeCollapsed,
setEditorMode,
setCommentsDisplay,
toggleOutlineSidebarExpanded,
toggleShowLayout,
toggleShowRuler,
toggleShowEquationToolbar,
toggleShowNonPrintableChars,
} = useDocsViewSettings()
const shellRef = useRef<HTMLDivElement>(null)
const presenceUsers = useCollabPresence(provider, { name: userName, color: userColor }) const presenceUsers = useCollabPresence(provider, { name: userName, color: userColor })
const pageLayout = useMemo( const pageLayout = useMemo(
() => resolveDocumentPageLayout(documentPageSetup, settings.pageFormatId), () => resolveDocumentPageLayout(documentPageSetup, settings.pageFormatId),
@ -120,9 +146,6 @@ export function RichTextDocumentEditor({
const persistPageSetup = useCallback( const persistPageSetup = useCallback(
async (setup: DocPageSetup) => { async (setup: DocPageSetup) => {
if (!editable) return if (!editable) return
setDocumentPageSetup(setup)
if (setup.formatId) setPageFormatId(setup.formatId)
reportSaveStatus("saving")
try { try {
const body = JSON.stringify({ pageSetup: setup }) const body = JSON.stringify({ pageSetup: setup })
if (session.saveUrl) { if (session.saveUrl) {
@ -143,16 +166,80 @@ export function RichTextDocumentEditor({
reportSaveStatus("error") reportSaveStatus("error")
} }
}, },
[editable, reportSaveStatus, session.canonicalPath, session.saveUrl, setPageFormatId] [editable, reportSaveStatus, session.canonicalPath, session.saveUrl]
)
const schedulePageSetupPatch = useCallback(
(patch: Partial<DocPageSetup>, 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( const handlePageFormatChange = useCallback(
(formatId: typeof settings.pageFormatId) => { (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<string, unknown>,
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<DocPageSetup>, options?: { immediate?: boolean }) => {
schedulePageSetupPatch(patch, options)
},
[schedulePageSetupPatch]
)
useEffect(() => {
documentPageSetupRef.current = documentPageSetup
}, [documentPageSetup])
useEffect(() => { useEffect(() => {
if (session.pageSetup) setDocumentPageSetup(session.pageSetup) if (session.pageSetup) setDocumentPageSetup(session.pageSetup)
}, [session.pageSetup]) }, [session.pageSetup])
@ -172,12 +259,20 @@ export function RichTextDocumentEditor({
const handlePageCountChange = useCallback((count: number) => { const handlePageCountChange = useCallback((count: number) => {
setPageCount(count) setPageCount(count)
setCurrentPage((page) => Math.min(page, count))
}, [])
const handleCurrentPageChange = useCallback((page: number) => {
setCurrentPage(page)
}, []) }, [])
const handlePurgeSidecarAndReimport = useCallback(async () => { const handlePurgeSidecarAndReimport = useCallback(async () => {
if (!editable) return if (!editable) return
const source = session.sourcePath || session.canonicalPath if (!session.sourcePath) {
if (!source || isUltidocPath(source)) { toast.error("Chemin source introuvable — ouvrez le document depuis le fichier DOCX")
return
}
if (isUltidocPath(session.sourcePath)) {
toast.error("Aucun fichier source à réimporter") toast.error("Aucun fichier source à réimporter")
return return
} }
@ -190,15 +285,29 @@ export function RichTextDocumentEditor({
} }
reportSaveStatus("saving") reportSaveStatus("saving")
purgeReimportingRef.current = true
reloadAfterReimportRef.current = true
try { 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}`) await apiClient.delete(`/drive/files${session.canonicalPath}`)
setDocumentPageSetup(null) setDocumentPageSetup(null)
setImportedContent(null) setImportedContent(null)
reloadAfterReimportRef.current = collaboration
setImportDone(false) setImportDone(false)
setContentImportPending(true) setContentImportPending(true)
toast.success("Sidecar purgé — réimport en cours…") toast.success("Sidecar purgé — réimport en cours…")
} catch { } catch {
purgeReimportingRef.current = false
reloadAfterReimportRef.current = false
reportSaveStatus("error") reportSaveStatus("error")
toast.error("Impossible de purger le sidecar") toast.error("Impossible de purger le sidecar")
} }
@ -218,6 +327,7 @@ export function RichTextDocumentEditor({
return () => { return () => {
if (saveTimer.current) clearTimeout(saveTimer.current) if (saveTimer.current) clearTimeout(saveTimer.current)
if (saveIdleTimer.current) clearTimeout(saveIdleTimer.current) if (saveIdleTimer.current) clearTimeout(saveIdleTimer.current)
if (pageSetupTimer.current) clearTimeout(pageSetupTimer.current)
} }
}, []) }, [])
@ -258,12 +368,18 @@ export function RichTextDocumentEditor({
void (async () => { void (async () => {
reportSaveStatus("saving") reportSaveStatus("saving")
try { 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 const buf = fetchSourceBytes
? await fetchSourceBytes(source) ? await fetchSourceBytes(source)
: await (await apiClient.getBlob(driveDownloadApiPath(source))).arrayBuffer() : await (await apiClient.getBlob(driveDownloadApiPath(source))).arrayBuffer()
const imported = await importFileToTipTap(source.split("/").pop() ?? "file.docx", buf) const imported = await importFileToTipTap(source.split("/").pop() ?? "file.docx", buf)
if (cancelled) return if (cancelled) return
if (isEmptyTipTapDoc(imported.content as Record<string, unknown>)) {
throw new Error("Le fichier source n'a produit aucun contenu importable")
}
const payload = { const payload = {
source_path: source, source_path: source,
content: imported.content, content: imported.content,
@ -275,27 +391,41 @@ export function RichTextDocumentEditor({
await apiClient.post("/richtext/import", payload) await apiClient.post("/richtext/import", payload)
} }
if (!cancelled) { if (!cancelled) {
setImportedContent(imported.content as Record<string, unknown>)
if (imported.pageSetup) setDocumentPageSetup(imported.pageSetup) if (imported.pageSetup) setDocumentPageSetup(imported.pageSetup)
setContentImportPending(false)
setImportDone(true)
reportSaveStatus("saved")
if (reloadAfterReimportRef.current) { if (reloadAfterReimportRef.current) {
reloadAfterReimportRef.current = false reloadAfterReimportRef.current = false
window.location.reload() window.location.reload()
return return
} }
setImportedContent(imported.content as Record<string, unknown>)
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 () => { return () => {
cancelled = true cancelled = true
} }
}, [contentImportPending, importDone, session, fetchSourceBytes, importApi, reportSaveStatus]) }, [
contentImportPending,
importDone,
session.sourcePath,
fetchSourceBytes,
importApi,
reportSaveStatus,
])
useEffect(() => { useEffect(() => {
if (purgeReimportingRef.current) return
if (!collaboration || !ydoc || !importDone) return if (!collaboration || !ydoc || !importDone) return
setCollabSynced(false) setCollabSynced(false)
@ -313,10 +443,12 @@ export function RichTextDocumentEditor({
setCollabSynced(false) setCollabSynced(false)
}, },
}) })
providerRef.current = p
setProvider(p) setProvider(p)
return () => { return () => {
p.destroy() p.destroy()
providerRef.current = null
setProvider(null) setProvider(null)
setCollabSynced(false) setCollabSynced(false)
} }
@ -330,7 +462,20 @@ export function RichTextDocumentEditor({
reportSaveStatus("saving") reportSaveStatus("saving")
} }
saveTimer.current = setTimeout(() => { saveTimer.current = setTimeout(() => {
const doc = { schemaVersion: 1, editor: "tiptap", content: json } 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 }
})
} catch {
/* keep base64 fallback */
}
const doc = { schemaVersion: 1, editor: "tiptap", content }
const body = JSON.stringify(doc) const body = JSON.stringify(doc)
const savePromise = session.saveUrl const savePromise = session.saveUrl
? fetch(session.saveUrl, { ? fetch(session.saveUrl, {
@ -340,10 +485,10 @@ export function RichTextDocumentEditor({
}).then((res) => { }).then((res) => {
if (!res.ok) throw new Error("save failed") if (!res.ok) throw new Error("save failed")
}) })
: apiClient.put("/richtext/save", { path: session.canonicalPath, document: json }) : apiClient.put("/richtext/save", { path: session.canonicalPath, document: content })
void savePromise await savePromise
.then(() => reportSaveStatus("saved")) reportSaveStatus("saved")
.catch(() => reportSaveStatus("error")) })().catch(() => reportSaveStatus("error"))
}, SAVE_DEBOUNCE_MS) }, SAVE_DEBOUNCE_MS)
}, },
[collaboration, editable, reportSaveStatus, session.canonicalPath, session.saveUrl] [collaboration, editable, reportSaveStatus, session.canonicalPath, session.saveUrl]
@ -416,7 +561,10 @@ export function RichTextDocumentEditor({
editor, editor,
pageSetup: documentPageSetup, pageSetup: documentPageSetup,
fallbackFormatId: settings.pageFormatId, fallbackFormatId: settings.pageFormatId,
onPageSetupApply: (setup) => void persistPageSetup(setup), onPageSetupApply: (setup) => {
documentPageSetupRef.current = setup
schedulePageSetupPatch(setup, { immediate: true })
},
onPurgeSidecarAndReimport: () => void handlePurgeSidecarAndReimport(), onPurgeSidecarAndReimport: () => void handlePurgeSidecarAndReimport(),
onShareClick: chrome?.onShareClick, onShareClick: chrome?.onShareClick,
onRenameRequest: chrome?.onRenameRequest, onRenameRequest: chrome?.onRenameRequest,
@ -429,6 +577,51 @@ export function RichTextDocumentEditor({
disabled: !editable, 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<DocsViewMenuState>(
() => ({
editorMode: settings.editorMode,
commentsDisplay: settings.commentsDisplay,
showLayout: settings.showLayout,
showRuler: settings.showRuler,
showEquationToolbar: settings.showEquationToolbar,
showNonPrintableChars: settings.showNonPrintableChars,
}),
[settings]
)
const viewMenuActions = useMemo<DocsViewMenuActions>(
() => ({
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 const chromeProps = chrome
? { ? {
...chrome, ...chrome,
@ -438,9 +631,34 @@ export function RichTextDocumentEditor({
editMenuActions: editMenu.actions, editMenuActions: editMenu.actions,
editMenuState: editMenu.state, editMenuState: editMenu.state,
editMenuDisabled: editMenu.disabled, editMenuDisabled: editMenu.disabled,
viewMenuActions,
viewMenuState,
viewMenuDisabled: false,
} }
: undefined : 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(() => { useEffect(() => {
if (!editor || collaboration || !importDone) return if (!editor || collaboration || !importDone) return
@ -522,10 +740,6 @@ export function RichTextDocumentEditor({
{...chromeProps} {...chromeProps}
saveStatus={saveStatus} saveStatus={saveStatus}
presenceUsers={presenceUsers} presenceUsers={presenceUsers}
pageFormatId={activePageFormatId}
onPageFormatChange={handlePageFormatChange}
zoom={settings.zoom}
onZoomChange={setZoom}
/> />
) : null} ) : null}
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground"> <div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
@ -536,21 +750,44 @@ export function RichTextDocumentEditor({
} }
return ( return (
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-background"> <div
ref={shellRef}
className={cn(
"flex min-h-0 flex-1 flex-col bg-white dark:bg-background",
settings.outlineSidebarExpanded && "docs-outline-sidebar-expanded"
)}
>
{chromeProps && !settings.chromeCollapsed ? ( {chromeProps && !settings.chromeCollapsed ? (
<DocsChrome <DocsChrome
{...chromeProps} {...chromeProps}
saveStatus={saveStatus} saveStatus={saveStatus}
presenceUsers={presenceUsers} presenceUsers={presenceUsers}
pageFormatId={activePageFormatId}
onPageFormatChange={handlePageFormatChange}
zoom={settings.zoom}
onZoomChange={setZoom}
/> />
) : null} ) : null}
{editable ? ( {chrome ? (
<DocsToolbar <DocsEditorWorkspace
editor={editor} editor={editor}
pageLayout={pageLayout}
zoom={settings.zoom}
editable={editable && settings.editorMode !== "view"}
showLayout={settings.showLayout}
showRuler={settings.showRuler}
showNonPrintableChars={settings.showNonPrintableChars}
editorMode={settings.editorMode}
outlineExpanded={settings.outlineSidebarExpanded}
onToggleOutline={toggleOutlineSidebarExpanded}
onPageCountChange={handlePageCountChange}
onCurrentPageChange={handleCurrentPageChange}
onRegionContentChange={handleRegionContentChange}
onPageSetupChange={handlePageSetupPatch}
onRegionEditorChange={setRegionEditor}
toolbarShellClassName={
settings.chromeCollapsed ? "docs-toolbar-shell--collapsed" : undefined
}
toolbar={
editable ? (
<DocsToolbar
editor={regionEditor ?? editor}
zoom={settings.zoom} zoom={settings.zoom}
onZoomChange={setZoom} onZoomChange={setZoom}
spellcheck={settings.spellcheck} spellcheck={settings.spellcheck}
@ -558,15 +795,10 @@ export function RichTextDocumentEditor({
showChromeToggle={Boolean(chrome)} showChromeToggle={Boolean(chrome)}
chromeCollapsed={settings.chromeCollapsed} chromeCollapsed={settings.chromeCollapsed}
onToggleChromeCollapsed={toggleChromeCollapsed} onToggleChromeCollapsed={toggleChromeCollapsed}
embedded
/> />
) : null} ) : null
{chrome ? ( }
<DocsPageView
editor={editor}
pageLayout={pageLayout}
zoom={settings.zoom}
editable={editable}
onPageCountChange={handlePageCountChange}
/> />
) : ( ) : (
<div className="min-h-0 flex-1 overflow-auto"> <div className="min-h-0 flex-1 overflow-auto">
@ -574,7 +806,11 @@ export function RichTextDocumentEditor({
</div> </div>
)} )}
{chrome ? ( {chrome ? (
<DocsStatusBar pageLayout={pageLayout} pageCount={pageCount} /> <DocsStatusBar
pageLayout={pageLayout}
pageCount={pageCount}
currentPage={currentPage}
/>
) : null} ) : null}
</div> </div>
) )

View File

@ -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<string, number>
}) {
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 (
<div key={`body-mask-${index}`} aria-hidden>
<div
className="pointer-events-none absolute z-[15]"
style={{
top: pageTop,
left: 0,
width: pageWidth,
height: headerBottom,
backgroundColor: pageColor,
}}
/>
<div
className="pointer-events-none absolute z-[15]"
style={{
top: footerTop,
left: 0,
width: pageWidth,
height: pageTop + pageHeight - footerTop,
backgroundColor: pageColor,
}}
/>
</div>
)
})}
</>
)
}

View File

@ -11,6 +11,7 @@ import { DocsLogoIcon } from "@/components/drive/richtext/docs-logo-icon"
import { DocsMenubar } from "@/components/drive/richtext/docs-menubar" import { DocsMenubar } from "@/components/drive/richtext/docs-menubar"
import type { DocsEditMenuActions, DocsEditMenuState } from "@/components/drive/richtext/docs-edit-menu" import type { DocsEditMenuActions, DocsEditMenuState } from "@/components/drive/richtext/docs-edit-menu"
import type { DocsFileMenuActions } from "@/components/drive/richtext/docs-file-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 { DocsMoveButton } from "@/components/drive/richtext/docs-move-button"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import type { DriveShare, DriveFileInfo } from "@/lib/api/types" import type { DriveShare, DriveFileInfo } from "@/lib/api/types"
@ -19,7 +20,6 @@ import {
resolveShareButtonIcon, resolveShareButtonIcon,
type ShareButtonIcon, type ShareButtonIcon,
} from "@/lib/drive/drive-share-button-state" } from "@/lib/drive/drive-share-button-state"
import type { PageFormatId } from "@/lib/drive/page-formats"
import type { RichTextSaveStatus } from "@/lib/drive/richtext-types" import type { RichTextSaveStatus } from "@/lib/drive/richtext-types"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -55,10 +55,9 @@ export function DocsChrome({
showAccount = false, showAccount = false,
saveStatus = "idle", saveStatus = "idle",
presenceUsers = [], presenceUsers = [],
pageFormatId, viewMenuActions,
onPageFormatChange, viewMenuState,
zoom, viewMenuDisabled,
onZoomChange,
trailing, trailing,
moveFile, moveFile,
onFileMoved, onFileMoved,
@ -82,10 +81,9 @@ export function DocsChrome({
showAccount?: boolean showAccount?: boolean
saveStatus?: RichTextSaveStatus saveStatus?: RichTextSaveStatus
presenceUsers?: CollabPresenceUser[] presenceUsers?: CollabPresenceUser[]
pageFormatId: PageFormatId viewMenuActions?: DocsViewMenuActions
onPageFormatChange: (id: PageFormatId) => void viewMenuState?: DocsViewMenuState
zoom: number viewMenuDisabled?: boolean
onZoomChange: (zoom: number) => void
trailing?: ReactNode trailing?: ReactNode
/** Propriétaire uniquement — affiche le bouton déplacer. */ /** Propriétaire uniquement — affiche le bouton déplacer. */
moveFile?: DriveFileInfo moveFile?: DriveFileInfo
@ -164,10 +162,9 @@ export function DocsChrome({
<div className="-mt-1 flex min-w-0 items-center overflow-x-auto overflow-y-visible"> <div className="-mt-1 flex min-w-0 items-center overflow-x-auto overflow-y-visible">
<DocsMenubar <DocsMenubar
className="docs-menubar shrink-0" className="docs-menubar shrink-0"
pageFormatId={pageFormatId} viewMenuActions={viewMenuActions}
onPageFormatChange={onPageFormatChange} viewMenuState={viewMenuState}
zoom={zoom} viewMenuDisabled={viewMenuDisabled}
onZoomChange={onZoomChange}
fileMenuActions={fileMenuActions} fileMenuActions={fileMenuActions}
fileMenuDisabled={fileMenuDisabled} fileMenuDisabled={fileMenuDisabled}
editMenuActions={editMenuActions} editMenuActions={editMenuActions}

View File

@ -0,0 +1,170 @@
"use client"
import { useEffect, useRef, useState, type ReactNode } from "react"
import type { Editor } from "@tiptap/react"
import { DocsPageView } from "@/components/drive/richtext/docs-page-view"
import {
DocsRulerToolbarRow,
DocsRulersLeftRail,
} from "@/components/drive/richtext/docs-rulers-chrome"
import type { DocPageLayout } from "@/lib/drive/doc-page-setup"
import { DOCS_VERTICAL_RULER_WIDTH_PX } from "@/lib/drive/docs-page-layout-constants"
import { docsZoomToScale } from "@/lib/drive/docs-ruler-scale"
import { useDocsRulerSync } from "@/lib/drive/use-docs-ruler-sync"
import { useDocsRulerMarginDrag } from "@/components/drive/richtext/use-docs-ruler-margin-drag"
import { DocsRulerMarginDragTooltip } from "@/components/drive/richtext/docs-ruler-markers"
import { cn } from "@/lib/utils"
export function DocsEditorWorkspace({
editor,
pageLayout,
zoom,
editable,
showLayout,
showRuler,
showNonPrintableChars,
editorMode,
outlineExpanded,
onToggleOutline,
onPageCountChange,
onCurrentPageChange,
toolbar,
toolbarShellClassName,
onRegionContentChange,
onPageSetupChange,
onRegionEditorChange,
}: {
editor: Editor
pageLayout: DocPageLayout
zoom: number
editable: boolean
showLayout: boolean
showRuler: boolean
showNonPrintableChars: boolean
editorMode: "edit" | "suggest" | "view"
outlineExpanded?: boolean
onToggleOutline?: () => void
onPageCountChange?: (count: number) => void
onCurrentPageChange?: (page: number) => void
toolbar?: ReactNode
toolbarShellClassName?: string
onRegionContentChange?: (
region: "header" | "footer",
content: Record<string, unknown>,
meta: { pageIndex: number; contentHeightPx: number }
) => void
onPageSetupChange?: (
patch: Partial<import("@/lib/drive/doc-page-setup").DocPageSetup>,
options?: { immediate?: boolean }
) => void
onRegionEditorChange?: (editor: import("@tiptap/react").Editor | null) => void
}) {
const canvasRef = useRef<HTMLDivElement>(null)
const rulerTrackRef = useRef<HTMLDivElement>(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 (
<div className="docs-editor-workspace flex min-h-0 flex-1 flex-col">
<DocsRulerMarginDragTooltip tooltip={dragTooltip} />
{showToolbarShell ? (
<div
className={cn(
"docs-toolbar-shell shrink-0",
toolbarShellClassName
)}
>
{toolbar}
{rulersVisible ? (
<DocsRulerToolbarRow
pageLayout={pageLayoutWithMargins}
scale={scale}
rulerSync={rulerSync}
rulerTrackRef={rulerTrackRef}
outlineExpanded={outlineExpanded}
onToggleOutline={onToggleOutline}
editable={marginsEditable}
onMarginDragStart={beginMarginDrag}
onMarginDrag={moveMarginDrag}
onMarginDragEnd={endMarginDrag}
/>
) : null}
</div>
) : null}
<div className="relative min-h-0 flex-1 overflow-hidden">
{rulersVisible ? (
<DocsRulersLeftRail
pageLayout={pageLayoutWithMargins}
scale={scale}
rulerSync={rulerSync}
editable={marginsEditable}
onMarginDragStart={beginMarginDrag}
onMarginDrag={moveMarginDrag}
onMarginDragEnd={endMarginDrag}
/>
) : null}
<div
className="flex h-full min-h-0 flex-col"
style={
rulersVisible
? { paddingLeft: DOCS_VERTICAL_RULER_WIDTH_PX }
: undefined
}
>
<DocsPageView
editor={editor}
pageLayout={pageLayoutWithMargins}
zoom={zoom}
editable={editable}
showLayout={showLayout}
showRuler={false}
showNonPrintableChars={showNonPrintableChars}
editorMode={editorMode}
canvasRef={canvasRef}
onPageCountChange={(count) => {
setPageCount(count)
onPageCountChange?.(count)
}}
onNarrowViewportChange={setNarrowViewport}
onRegionContentChange={onRegionContentChange}
onPageSetupChange={onPageSetupChange}
onRegionEditorChange={onRegionEditorChange}
/>
</div>
</div>
</div>
)
}

View File

@ -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<ExclusiveMenuSubContextValue | null>(null)
/** Ensures only one MenubarSub stays open while hovering across sibling sub-triggers. */
export function DocsExclusiveMenuSubRoot({ children }: { children: ReactNode }) {
const [openId, setOpenId] = useState<string | null>(null)
return (
<ExclusiveMenuSubContext.Provider value={{ openId, setOpenId }}>
{children}
</ExclusiveMenuSubContext.Provider>
)
}
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 <MenubarSub>{children}</MenubarSub>
}
return (
<MenubarSub open={open} onOpenChange={onOpenChange}>
{children}
</MenubarSub>
)
}

View File

@ -25,11 +25,14 @@ import {
MenubarItem, MenubarItem,
MenubarMenu, MenubarMenu,
MenubarSeparator, MenubarSeparator,
MenubarSub,
MenubarSubContent, MenubarSubContent,
MenubarSubTrigger, MenubarSubTrigger,
MenubarTrigger, MenubarTrigger,
} from "@/components/ui/menubar" } 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 { DOCS_MENUBAR_CONTENT_PROPS } from "@/components/drive/richtext/docs-menubar-props"
import { DocsLogoIcon } from "@/components/drive/richtext/docs-logo-icon" import { DocsLogoIcon } from "@/components/drive/richtext/docs-logo-icon"
import { DocsMenuShortcut } from "@/components/drive/richtext/docs-menu-shortcut" 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" className="docs-menu-content min-w-[280px] overflow-visible"
data-docs-menu-surface data-docs-menu-surface
> >
<MenubarSub> <DocsExclusiveMenuSubRoot>
<DocsExclusiveMenuSub id="new">
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}> <MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
<MenuIcon> <MenuIcon>
<FileText className="size-4" /> <FileText className="size-4" />
@ -106,7 +110,7 @@ export function DocsFileMenu({
À partir de la galerie de modèles À partir de la galerie de modèles
</MenubarItem> </MenubarItem>
</MenubarSubContent> </MenubarSubContent>
</MenubarSub> </DocsExclusiveMenuSub>
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onOpen}> <MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onOpen}>
<MenuIcon> <MenuIcon>
@ -125,7 +129,7 @@ export function DocsFileMenu({
<MenubarSeparator /> <MenubarSeparator />
<MenubarSub> <DocsExclusiveMenuSub id="share">
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}> <MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
<MenuIcon> <MenuIcon>
<UserPlus className="size-4" /> <UserPlus className="size-4" />
@ -146,9 +150,9 @@ export function DocsFileMenu({
Publier sur le Web Publier sur le Web
</MenubarItem> </MenubarItem>
</MenubarSubContent> </MenubarSubContent>
</MenubarSub> </DocsExclusiveMenuSub>
<MenubarSub> <DocsExclusiveMenuSub id="email">
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}> <MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
<MenuIcon> <MenuIcon>
<Mail className="size-4" /> <Mail className="size-4" />
@ -170,9 +174,9 @@ export function DocsFileMenu({
Brouillon d&apos;e-mail Brouillon d&apos;e-mail
</MenubarItem> </MenubarItem>
</MenubarSubContent> </MenubarSubContent>
</MenubarSub> </DocsExclusiveMenuSub>
<MenubarSub> <DocsExclusiveMenuSub id="download">
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}> <MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
<MenuIcon> <MenuIcon>
<Download className="size-4" /> <Download className="size-4" />
@ -191,7 +195,7 @@ export function DocsFileMenu({
</MenubarItem> </MenubarItem>
))} ))}
</MenubarSubContent> </MenubarSubContent>
</MenubarSub> </DocsExclusiveMenuSub>
<MenubarSeparator /> <MenubarSeparator />
@ -229,7 +233,7 @@ export function DocsFileMenu({
<MenubarSeparator /> <MenubarSeparator />
<MenubarSub> <DocsExclusiveMenuSub id="version-history">
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}> <MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
<MenuIcon> <MenuIcon>
<History className="size-4" /> <History className="size-4" />
@ -252,7 +256,7 @@ export function DocsFileMenu({
Afficher l&apos;historique des versions Afficher l&apos;historique des versions
</MenubarItem> </MenubarItem>
</MenubarSubContent> </MenubarSubContent>
</MenubarSub> </DocsExclusiveMenuSub>
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onToggleOffline}> <MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onToggleOffline}>
<MenuIcon> <MenuIcon>
@ -307,6 +311,7 @@ export function DocsFileMenu({
Imprimer Imprimer
<DocsMenuShortcut shortcutId="file.print" /> <DocsMenuShortcut shortcutId="file.print" />
</MenubarItem> </MenubarItem>
</DocsExclusiveMenuSubRoot>
</MenubarContent> </MenubarContent>
</MenubarMenu> </MenubarMenu>
) )

View File

@ -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<string, unknown>)
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 (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="min-w-52">
<ContextMenuItem
onClick={() => document.execCommand("cut")}
>
Couper
</ContextMenuItem>
<ContextMenuItem
onClick={() => document.execCommand("copy")}
>
Copier
</ContextMenuItem>
<ContextMenuItem
onClick={() => document.execCommand("paste")}
>
Coller
</ContextMenuItem>
<ContextMenuItem
onClick={() => editor.chain().focus().deleteSelection().run()}
>
Supprimer
</ContextMenuItem>
<ContextMenuSeparator />
{isImage ? (
<>
<ContextMenuItem onClick={onReplaceImage}>Remplacer l&apos;image</ContextMenuItem>
<ContextMenuItem onClick={onCrop}>Recadrer</ContextMenuItem>
<ContextMenuItem onClick={onOpenOptions}>Options image</ContextMenuItem>
<ContextMenuItem
onClick={() => {
const alt = window.prompt("Texte alternatif", attrs.alt)
if (alt != null) editor.chain().focus().updateDocsGraphic({ alt }).run()
}}
>
Texte alternatif
</ContextMenuItem>
<ContextMenuItem onClick={downloadImage} disabled={!attrs.src}>
Télécharger l&apos;image
</ContextMenuItem>
</>
) : attrs.graphicType === "shape" ? (
<>
<ContextMenuItem
onClick={() => {
const fill = window.prompt("Couleur de remplissage", attrs.fill)
if (fill) editor.chain().focus().updateDocsGraphic({ fill }).run()
}}
>
Modifier le remplissage
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
const stroke = window.prompt("Couleur du contour", attrs.stroke)
if (stroke) editor.chain().focus().updateDocsGraphic({ stroke }).run()
}}
>
Modifier le contour
</ContextMenuItem>
</>
) : null}
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>Habillage texte</ContextMenuSubTrigger>
<ContextMenuSubContent>
{(Object.keys(DOCS_GRAPHIC_WRAP_LABELS) as DocsGraphicWrap[]).map((wrap) => (
<ContextMenuItem key={wrap} onClick={() => applyWrap(wrap)}>
{DOCS_GRAPHIC_WRAP_LABELS[wrap]}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => editor.chain().focus().bringDocsGraphicForward().run()}>
Avancer
</ContextMenuItem>
<ContextMenuItem onClick={() => editor.chain().focus().sendDocsGraphicBackward().run()}>
Reculer
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}

View File

@ -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<Handle, string> = {
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<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">,
dxNorm: number,
dyNorm: number
): Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight"> {
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<DocsGraphicAttrs>) => void
onDone: () => void
}) {
const dragRef = useRef<{
handle: Handle
startX: number
startY: number
origin: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">
} | 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 (
<div className="docs-graphic-crop absolute inset-0 z-30" aria-label="Recadrage">
<div className="absolute inset-0 bg-black/40" />
<div
className={cn(
"absolute border-2 border-white shadow-[0_0_0_1px_#1a73e8]",
attrs.cropShape === "ellipse" && "rounded-full"
)}
style={{ left, top, width, height }}
>
{HANDLES.map((handle) => (
<span
key={handle}
role="presentation"
className={cn(
"absolute z-40 size-2.5 rounded-full border border-white bg-[#1a73e8] shadow",
HANDLE_CLASS[handle]
)}
onPointerDown={(event) => onHandleDown(handle, event)}
/>
))}
</div>
</div>
)
}

View File

@ -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 (
<svg width="100%" height="100%" viewBox="0 0 100 100" aria-hidden>
<ellipse cx="50" cy="50" rx="46" ry="40" fill={fill} stroke={stroke} strokeWidth={strokeWidth} />
</svg>
)
}
if (shapeType === "line") {
return (
<svg width="100%" height="100%" viewBox="0 0 100 100" aria-hidden>
<line x1="8" y1="50" x2="92" y2="50" stroke={stroke} strokeWidth={strokeWidth + 1} />
</svg>
)
}
if (shapeType === "arrow") {
return (
<svg width="100%" height="100%" viewBox="0 0 100 100" aria-hidden>
<line x1="10" y1="50" x2="78" y2="50" stroke={stroke} strokeWidth={strokeWidth + 1} />
<polygon points="78,38 92,50 78,62" fill={stroke} />
</svg>
)
}
return (
<svg width="100%" height="100%" viewBox="0 0 100 100" aria-hidden>
<rect x="6" y="10" width="88" height="80" rx="4" fill={fill} stroke={stroke} strokeWidth={strokeWidth} />
</svg>
)
}
function GraphicContent({ attrs }: { attrs: DocsGraphicAttrs }) {
if (attrs.graphicType === "image") {
if (!attrs.src) {
return (
<div className="flex h-full w-full items-center justify-center bg-[#f1f3f4] text-xs text-[#5f6368]">
Image
</div>
)
}
const cropStyle = computeCropImageStyle(attrs)
return (
<img
src={attrs.src}
alt={attrs.alt || ""}
draggable={false}
className="block h-full w-full"
style={{
objectFit: Object.keys(cropStyle.img).length ? undefined : "contain",
...cropStyle.img,
clipPath: cropStyle.clipPath,
}}
/>
)
}
if (attrs.graphicType === "gradient") {
return (
<div
className="h-full w-full"
style={{ background: attrs.gradientCss }}
aria-hidden
/>
)
}
return (
<ShapePreview
shapeType={attrs.shapeType}
fill={attrs.fill}
stroke={attrs.stroke}
strokeWidth={attrs.strokeWidth}
/>
)
}
function ResizeHandleBtn({
handle,
onPointerDown,
}: {
handle: ResizeHandle
onPointerDown: (handle: ResizeHandle, event: React.PointerEvent) => void
}) {
const posClass: Record<ResizeHandle, string> = {
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 (
<span
role="presentation"
className={cn(
"docs-graphic-handle absolute z-20 size-2.5 rounded-full border border-white bg-[#1a73e8] shadow",
posClass[handle]
)}
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
onPointerDown(handle, event)
}}
/>
)
}
function RotationHandle({
onPointerDown,
}: {
onPointerDown: (event: React.PointerEvent) => void
}) {
return (
<span
role="presentation"
className="docs-graphic-rotate-handle absolute left-1/2 top-0 z-20 flex -translate-x-1/2 -translate-y-[calc(100%+8px)] cursor-grab items-center justify-center"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
onPointerDown(event)
}}
>
<span className="size-2.5 rounded-full border border-white bg-[#1a73e8] shadow" />
<span className="absolute top-full h-2 w-px bg-[#1a73e8]" aria-hidden />
</span>
)
}
function DocsGraphicNodeViewInner({
node,
updateAttributes,
selected,
editor,
getPos,
extension,
}: NodeViewProps) {
const attrs = parseGraphicAttrs(node.attrs as Record<string, unknown>)
const layout = computeGraphicLayoutStyle(attrs)
const editable = editor.isEditable
const inline = extension.name === "docsInlineGraphic"
const replaceInputRef = useRef<HTMLInputElement>(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<DocsGraphicAttrs> = {
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 = (
<div
className={cn(
"docs-graphic",
selected && "docs-graphic--selected",
interacting && "docs-graphic--interacting",
cropMode && "docs-graphic--cropping",
layout.behindText && "docs-graphic--behind",
layout.inFrontText && "docs-graphic--front"
)}
style={layout.inner}
contentEditable={false}
onPointerDown={(event) => {
selectNode()
onDragPointerDown(event)
}}
onDoubleClick={(event) => {
if (!editable || attrs.graphicType !== "image") return
event.preventDefault()
event.stopPropagation()
setCropMode(true)
}}
>
<div className="docs-graphic__content" style={layout.content}>
<GraphicContent attrs={attrs} />
</div>
{selected && editable && cropMode && attrs.graphicType === "image" ? (
<DocsGraphicCropOverlay
attrs={attrs}
frameWidth={attrs.width}
frameHeight={attrs.height}
onChange={updateAttributes}
onDone={() => setCropMode(false)}
/>
) : null}
{selected && editable && !cropMode ? (
<>
<span className="docs-graphic-outline" aria-hidden />
<RotationHandle onPointerDown={onRotateStart} />
{RESIZE_HANDLES.map((handle) => (
<ResizeHandleBtn key={handle} handle={handle} onPointerDown={onResizeStart} />
))}
</>
) : null}
<input
ref={replaceInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
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 = ""
}}
/>
</div>
)
return (
<NodeViewWrapper
as={inline ? "span" : "div"}
className={cn("docs-graphic-host", inline && "docs-graphic-host--inline")}
style={layout.wrapper}
data-graphic-type={attrs.graphicType}
data-wrap={attrs.wrap}
data-placement={attrs.placement}
>
{selected && editable ? (
<DocsGraphicContextMenu
editor={editor}
onCrop={() => setCropMode(true)}
onReplaceImage={replaceImage}
>
{graphicBody}
</DocsGraphicContextMenu>
) : (
graphicBody
)}
</NodeViewWrapper>
)
}
export const DocsGraphicNodeView = memo(DocsGraphicNodeViewInner)

View File

@ -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<string, unknown>)
const update = (patch: Record<string, unknown>) => {
editor.chain().focus().updateDocsGraphic(patch).run()
}
const numField = (
label: string,
key: "width" | "height" | "x" | "y" | "rotationDeg",
step = 1
) => (
<div className="grid gap-1">
<Label className="text-xs text-muted-foreground">{label}</Label>
<Input
type="number"
className="h-8"
disabled={disabled}
value={attrs[key]}
step={step}
onChange={(e) => update({ [key]: Number(e.target.value) || 0 })}
/>
</div>
)
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="docs-toolbar-btn h-7 shrink-0 px-2 text-xs"
disabled={disabled}
>
Options
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 space-y-3 p-3" align="start">
<p className="text-sm font-medium">
{attrs.graphicType === "image"
? "Options image"
: attrs.graphicType === "shape"
? "Options forme"
: "Options dégradé"}
</p>
<div className="grid grid-cols-2 gap-2">
{numField("Largeur (px)", "width")}
{numField("Hauteur (px)", "height")}
{numField("X", "x")}
{numField("Y", "y")}
{numField("Rotation (°)", "rotationDeg")}
</div>
<div className="grid gap-1">
<Label className="text-xs text-muted-foreground">Habillage</Label>
<Select
disabled={disabled}
value={attrs.wrap}
onValueChange={(v) => update({ wrap: v as DocsGraphicWrap })}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(DOCS_GRAPHIC_WRAP_LABELS) as DocsGraphicWrap[]).map((wrap) => (
<SelectItem key={wrap} value={wrap}>
{DOCS_GRAPHIC_WRAP_LABELS[wrap]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label className="text-xs text-muted-foreground">Placement</Label>
<Select
disabled={disabled}
value={attrs.placement}
onValueChange={(v) => update({ placement: v as DocsGraphicPlacement })}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(DOCS_GRAPHIC_PLACEMENT_LABELS) as DocsGraphicPlacement[]).map(
(placement) => (
<SelectItem key={placement} value={placement}>
{DOCS_GRAPHIC_PLACEMENT_LABELS[placement]}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
{attrs.graphicType === "shape" ? (
<div className="grid grid-cols-2 gap-2">
<div className="grid gap-1">
<Label className="text-xs text-muted-foreground">Remplissage</Label>
<Input
type="color"
className="h-8 p-1"
disabled={disabled}
value={attrs.fill}
onChange={(e) => update({ fill: e.target.value })}
/>
</div>
<div className="grid gap-1">
<Label className="text-xs text-muted-foreground">Contour</Label>
<Input
type="color"
className="h-8 p-1"
disabled={disabled}
value={attrs.stroke}
onChange={(e) => update({ stroke: e.target.value })}
/>
</div>
<div className="col-span-2 grid gap-1">
<Label className="text-xs text-muted-foreground">Épaisseur contour</Label>
<Input
type="number"
className="h-8"
disabled={disabled}
min={0}
value={attrs.strokeWidth}
onChange={(e) => update({ strokeWidth: Number(e.target.value) || 0 })}
/>
</div>
</div>
) : null}
{attrs.graphicType === "gradient" ? (
<div className="grid grid-cols-2 gap-2">
<div className="grid gap-1">
<Label className="text-xs text-muted-foreground">Couleur 1</Label>
<Input
type="color"
className="h-8 p-1"
disabled={disabled}
value={attrs.gradientColor1}
onChange={(e) => {
const gradientColor1 = e.target.value
update({
gradientColor1,
gradientCss: buildGradientCss(
attrs.gradientAngle,
gradientColor1,
attrs.gradientColor2
),
})
}}
/>
</div>
<div className="grid gap-1">
<Label className="text-xs text-muted-foreground">Couleur 2</Label>
<Input
type="color"
className="h-8 p-1"
disabled={disabled}
value={attrs.gradientColor2}
onChange={(e) => {
const gradientColor2 = e.target.value
update({
gradientColor2,
gradientCss: buildGradientCss(
attrs.gradientAngle,
attrs.gradientColor1,
gradientColor2
),
})
}}
/>
</div>
<div className="col-span-2 grid gap-1">
<Label className="text-xs text-muted-foreground">Angle (°)</Label>
<Input
type="number"
className="h-8"
disabled={disabled}
value={attrs.gradientAngle}
onChange={(e) => {
const gradientAngle = Number(e.target.value) || 0
update({
gradientAngle,
gradientCss: buildGradientCss(
gradientAngle,
attrs.gradientColor1,
attrs.gradientColor2
),
})
}}
/>
</div>
</div>
) : null}
{attrs.graphicType === "image" ? (
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
disabled={disabled}
onClick={() =>
update({ cropX: 0, cropY: 0, cropWidth: 1, cropHeight: 1 })
}
>
Réinitialiser le recadrage
</Button>
) : null}
</PopoverContent>
</Popover>
)
}

View File

@ -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<string, unknown>)
}
if (editor.isActive("docsInlineGraphic")) {
return parseGraphicAttrs(editor.getAttributes("docsInlineGraphic") as Record<string, unknown>)
}
return null
}
export function DocsGraphicInsertMenu({
editor,
disabled,
}: {
editor: Editor | null
disabled?: boolean
}) {
const imageInputRef = useRef<HTMLInputElement>(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 (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="docs-toolbar-btn size-7 shrink-0"
disabled={disabled}
aria-label="Insérer un élément graphique"
>
<Icon icon="material-symbols:image-outline" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-56">
<DropdownMenuItem onClick={() => imageInputRef.current?.click()}>
Image
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Forme</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{(["rect", "ellipse", "line", "arrow"] as const).map((shapeType) => (
<DropdownMenuItem
key={shapeType}
onClick={() =>
editor
.chain()
.focus()
.insertDocsGraphic(buildInsertGraphicAttrs("shape", { shapeType }))
.run()
}
>
{shapeType === "rect"
? "Rectangle"
: shapeType === "ellipse"
? "Ellipse"
: shapeType === "line"
? "Ligne"
: "Flèche"}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem
onClick={() =>
editor
.chain()
.focus()
.insertDocsGraphic(buildInsertGraphicAttrs("gradient"))
.run()
}
>
Dégradé
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="docs-toolbar-btn docs-toolbar-btn--active size-7 shrink-0"
disabled={disabled}
aria-label="Disposition de l'élément graphique"
>
<Icon icon="material-symbols:layers-outline" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-64">
<DropdownMenuSub>
<DropdownMenuSubTrigger>Habillage texte</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{(Object.keys(DOCS_GRAPHIC_WRAP_LABELS) as DocsGraphicWrap[]).map((wrap) => (
<DropdownMenuItem key={wrap} onClick={() => applyWrap(wrap)}>
{DOCS_GRAPHIC_WRAP_LABELS[wrap]}
{attrs.wrap === wrap ? " ✓" : ""}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>Placement</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{(Object.keys(DOCS_GRAPHIC_PLACEMENT_LABELS) as DocsGraphicPlacement[]).map(
(placement) => (
<DropdownMenuItem key={placement} onClick={() => applyPlacement(placement)}>
{DOCS_GRAPHIC_PLACEMENT_LABELS[placement]}
{attrs.placement === placement ? " ✓" : ""}
</DropdownMenuItem>
)
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>Côté du flottement</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{(["left", "right", "center"] as const).map((side) => (
<DropdownMenuItem key={side} onClick={() => applyFloatSide(side)}>
{side === "left" ? "Gauche" : side === "right" ? "Droite" : "Centre"}
{attrs.floatSide === side ? " ✓" : ""}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function readGraphicToolbarActive(editor: Editor | null): boolean {
if (!editor) return false
return editor.isActive("docsGraphic") || editor.isActive("docsInlineGraphic")
}

View File

@ -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<DocPageSetup>) => 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>En-têtes et pieds de page</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<p className="mb-2 text-sm font-medium">Marges</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
En-tête (cm depuis le haut)
</Label>
<Input
type="number"
min={0}
step={0.1}
value={headerMarginCm}
onChange={(e) => setHeaderMarginCm(Number(e.target.value) || 0)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
Pied de page (cm depuis le bas)
</Label>
<Input
type="number"
min={0}
step={0.1}
value={footerMarginCm}
onChange={(e) => setFooterMarginCm(Number(e.target.value) || 0)}
/>
</div>
</div>
</div>
<div>
<p className="mb-2 text-sm font-medium">Mise en page</p>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={differentFirst}
onCheckedChange={(v) => setDifferentFirst(v === true)}
/>
Première page différente
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={differentOddEven}
onCheckedChange={(v) => setDifferentOddEven(v === true)}
/>
Différente pour les pages paires et impaires
</label>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button
type="button"
onClick={() => {
onApply({
headerMarginMm: cmToMm(headerMarginCm),
footerMarginMm: cmToMm(footerMarginCm),
headerFooterDifferentFirstPage: differentFirst,
headerFooterDifferentOddEven: differentOddEven,
})
onOpenChange(false)
}}
>
Appliquer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Numéros de page</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<p className="mb-2 text-sm font-medium">Position</p>
<div className="flex gap-4 text-sm">
<label className="flex items-center gap-2">
<input
type="radio"
name="page-num-placement"
checked={placement === "header"}
onChange={() => setPlacement("header")}
/>
En-tête
</label>
<label className="flex items-center gap-2">
<input
type="radio"
name="page-num-placement"
checked={placement === "footer"}
onChange={() => setPlacement("footer")}
/>
Pied de page
</label>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={showOnFirstPage}
onCheckedChange={(v) => setShowOnFirstPage(v === true)}
/>
Afficher sur la première page
</label>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Commencer à</Label>
<Input
type="number"
min={0}
value={startAt}
onChange={(e) => setStartAt(Number(e.target.value) || 0)}
className="w-24"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button
type="button"
onClick={() => {
onApply({
enabled: true,
placement,
align: "right",
startAt,
showOnFirstPage,
})
onOpenChange(false)
}}
>
Appliquer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -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<string, unknown> | undefined): string {
if (!content) return ""
const parts: string[] = []
const walk = (node: unknown) => {
if (!node || typeof node !== "object") return
const record = node as Record<string, unknown>
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 (
<div
className={cn(
"docs-hf-chrome pointer-events-none absolute",
placement === "footer" && "docs-hf-chrome--footer"
)}
style={{ top: barTop, left: 0, width: pageWidth, height: DOCS_HF_CHROME_BAR_PX }}
>
<div
className={cn(
"docs-hf-chrome__bar pointer-events-auto flex h-full items-center justify-between bg-[#f1f3f4] px-4",
placement === "header" ? "border-t border-[#dadce0]" : "border-b border-[#dadce0]"
)}
onMouseDown={(event) => event.preventDefault()}
>
<span className="docs-hf-chrome__label">{label}</span>
<div className="flex items-center gap-4">
{showFirstPageCheckbox ? (
<label className="docs-hf-chrome__checkbox-label flex cursor-pointer items-center gap-2">
<Checkbox
checked={differentFirstPage}
onCheckedChange={(v) => onDifferentFirstPageChange(v === true)}
className="docs-hf-chrome__checkbox"
/>
Première page différente
</label>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="docs-hf-chrome__options h-7 gap-1 px-1.5 hover:bg-transparent dark:hover:bg-transparent"
>
Options
<ChevronDown className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onFormatOpen}>
{label === "En-tête"
? "Format de l'en-tête"
: "Format du pied de page"}
</DropdownMenuItem>
<DropdownMenuItem onClick={onPageNumOpen}>
Numéros de page
</DropdownMenuItem>
<DropdownMenuItem onClick={onRemove}>
{label === "En-tête"
? "Supprimer l'en-tête"
: "Supprimer le pied de page"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
)
}
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<string, unknown>,
meta: { pageIndex: number; contentHeightPx: number },
options?: { immediate?: boolean }
) => void
onPageSetupChange: (patch: Partial<DocPageSetup>) => 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<ReturnType<typeof setTimeout> | null>(null)
const latestContentRef = useRef<Record<string, unknown> | null>(null)
const persistRegionContent = useCallback(
(content: Record<string, unknown>, 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<string, unknown>) => {
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<string, unknown>)),
true
)
}
wasEditingRef.current = isEditing
}, [isEditing, persistRegionContent, regionData?.content])
return (
<>
{isEditing ? (
<>
<div
className="docs-hf-editing-backdrop pointer-events-none absolute"
style={{
top: zoneTop,
left: 0,
width: pageWidth,
height: contentHeight,
backgroundColor: pageLayout.pageColor,
}}
aria-hidden
/>
{isHeader ? (
<div
className="docs-hf-separator pointer-events-none absolute border-t border-[#dadce0]"
style={{ top: headerGeom.zoneTop, left: 0, width: pageWidth }}
aria-hidden
/>
) : (
<div
className="docs-hf-separator pointer-events-none absolute border-b border-[#dadce0]"
style={{ top: footerGeom.zoneBottom, left: 0, width: pageWidth }}
aria-hidden
/>
)}
<DocsHeaderFooterChrome
label={regionLabel}
pageWidth={pageWidth}
barTop={chromeBarTop}
placement={region}
showFirstPageCheckbox={pageIndex === 0}
differentFirstPage={differentFirstPage}
onDifferentFirstPageChange={(checked) => {
onPageSetupChange(toggleDifferentFirstPage(setupPatch, checked))
}}
onFormatOpen={() => setFormatOpen(true)}
onPageNumOpen={() => setPageNumOpen(true)}
onRemove={handleRemove}
/>
</>
) : null}
{!isEditing && !hasContent && canEdit ? (
<div
className="docs-hf-hit-area absolute cursor-text"
style={{
top: isHeader ? headerGeom.zoneTop : footerGeom.zoneBottom - minZoneHeight,
left: bandLeft,
width: bandWidth,
height: minZoneHeight,
}}
onDoubleClick={handleDoubleClick}
aria-hidden
/>
) : null}
{isEditing || hasContent ? (
<div
className={cn(
"docs-hf-band absolute",
isHeader ? "docs-hf-band--header" : "docs-hf-band--footer",
isEditing && "docs-hf-band--editing"
)}
style={{
top: zoneTop,
left: bandLeft,
width: bandWidth,
height: contentHeight,
...(isEditing
? {
backgroundColor: pageLayout.pageColor,
"--docs-hf-page-color": pageLayout.pageColor,
}
: {}),
}}
onDoubleClick={handleDoubleClick}
>
<DocsRegionEditor
content={regionData?.content}
editable={isEditing}
autoFocus={isEditing}
placeholder={regionLabel}
minHeightPx={isEditing ? minZoneHeight : undefined}
onUpdate={isEditing ? scheduleRegionContentPersist : undefined}
onBlur={
isEditing
? () => {
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 ? (
<div
className={cn(
"pointer-events-none absolute text-[11px] text-[#5f6368]",
pageLayout.pageNumbers?.align === "center"
? "left-1/2 -translate-x-1/2"
: pageLayout.pageNumbers?.align === "left"
? "left-0"
: "right-0",
isHeader ? "top-0" : "bottom-0"
)}
>
{pageNumber}
</div>
) : null}
</div>
) : null}
{isEditing ? (
<>
<DocsHeaderFooterFormatDialog
open={formatOpen}
onOpenChange={setFormatOpen}
pageSetup={setupPatch}
onApply={onPageSetupChange}
/>
<DocsPageNumbersDialog
open={pageNumOpen}
onOpenChange={setPageNumOpen}
settings={pageLayout.pageNumbers}
onApply={(pageNumbers) => onPageSetupChange({ pageNumbers })}
/>
</>
) : null}
</>
)
}

View File

@ -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<HTMLDivElement>(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<HTMLDivElement>) => void) =>
(event: React.PointerEvent<HTMLDivElement>) => {
onMarginDragStart?.(side)
handler(event)
}
return (
<div
ref={rulerRef}
data-docs-ruler="horizontal"
data-docs-ruler-scale={scale}
className="docs-horizontal-ruler relative overflow-visible bg-transparent"
style={{
width: scaledWidth,
height: DOCS_HORIZONTAL_RULER_HEIGHT_PX,
}}
>
<div
className="absolute top-0 h-full bg-transparent"
style={{ left: 0, width: s(margins.left) }}
/>
<div
className="absolute top-0 h-full bg-transparent"
style={{ left: s(pageWidth - margins.right), width: s(margins.right) }}
/>
{ticks.map((tick, index) => (
<div
key={`${tick.pos}-${index}`}
className="pointer-events-none absolute bottom-0 w-px bg-[#80868b] dark:bg-muted-foreground/70"
style={{
left: s(tick.pos),
height: tick.major ? 10 : 5,
}}
/>
))}
{ticks
.filter((tick) => tick.major && tick.label != null)
.map((tick) => (
<span
key={`label-${tick.pos}`}
className="pointer-events-none absolute top-[2px] -translate-x-1/2 text-[9px] leading-none text-[#5f6368] dark:text-muted-foreground"
style={{ left: s(tick.pos) }}
>
{tick.label}
</span>
))}
<DocsRulerDraggableHandle
style={{ left: s(margins.left) }}
axis="horizontal"
disabled={!editable}
ariaLabel="Marge gauche"
onPointerDown={wrapMarginDown("left", leftDrag.onPointerDown)}
>
<DocsRulerTriangleMarker left={0} className="relative" />
</DocsRulerDraggableHandle>
<DocsRulerDraggableHandle
style={{ left: s(pageWidth - margins.right) }}
axis="horizontal"
disabled={!editable}
ariaLabel="Marge droite"
onPointerDown={wrapMarginDown("right", rightDrag.onPointerDown)}
>
<DocsRulerTriangleMarker left={0} className="relative" />
</DocsRulerDraggableHandle>
<DocsRulerTriangleMarker
left={s(indents.leftPx)}
className="pointer-events-none absolute bottom-0 -translate-x-1/2"
/>
{Math.abs(indents.firstLinePx - indents.leftPx) > 1 ? (
<DocsRulerFirstLineMarker left={s(indents.firstLinePx)} />
) : null}
</div>
)
}
export const DocsHorizontalRuler = memo(DocsHorizontalRulerInner)

View File

@ -2,31 +2,23 @@
import { DocsEditMenu, type DocsEditMenuActions, type DocsEditMenuState } from "@/components/drive/richtext/docs-edit-menu" 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 { 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 { DOCS_MENUBAR_CONTENT_PROPS } from "@/components/drive/richtext/docs-menubar-props"
import { import {
Menubar, Menubar,
MenubarContent, MenubarContent,
MenubarItem, MenubarItem,
MenubarMenu, MenubarMenu,
MenubarSeparator,
MenubarTrigger, MenubarTrigger,
} from "@/components/ui/menubar" } from "@/components/ui/menubar"
import { PAGE_FORMATS, type PageFormatId } from "@/lib/drive/page-formats"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const OTHER_MENU_LABELS = [ const OTHER_MENU_LABELS = ["Insertion", "Format", "Outils", "Aide"] as const
"Affichage",
"Insertion",
"Format",
"Outils",
"Aide",
] as const
export function DocsMenubar({ export function DocsMenubar({
pageFormatId, viewMenuActions,
onPageFormatChange, viewMenuState,
zoom, viewMenuDisabled,
onZoomChange,
fileMenuActions, fileMenuActions,
fileMenuDisabled, fileMenuDisabled,
editMenuActions, editMenuActions,
@ -34,10 +26,9 @@ export function DocsMenubar({
editMenuDisabled, editMenuDisabled,
className, className,
}: { }: {
pageFormatId: PageFormatId viewMenuActions?: DocsViewMenuActions
onPageFormatChange: (id: PageFormatId) => void viewMenuState?: DocsViewMenuState
zoom: number viewMenuDisabled?: boolean
onZoomChange: (zoom: number) => void
fileMenuActions?: DocsFileMenuActions fileMenuActions?: DocsFileMenuActions
fileMenuDisabled?: boolean fileMenuDisabled?: boolean
editMenuActions?: DocsEditMenuActions editMenuActions?: DocsEditMenuActions
@ -82,54 +73,24 @@ export function DocsMenubar({
</MenubarMenu> </MenubarMenu>
)} )}
{OTHER_MENU_LABELS.map((label) => { {viewMenuActions && viewMenuState ? (
if (label === "Affichage") { <DocsViewMenu
return ( actions={viewMenuActions}
<MenubarMenu key={label}> state={viewMenuState}
<MenubarTrigger className="docs-menu-trigger">{label}</MenubarTrigger> disabled={viewMenuDisabled}
<MenubarContent />
{...DOCS_MENUBAR_CONTENT_PROPS} ) : (
className="docs-menu-content min-w-52 overflow-visible" <MenubarMenu>
data-docs-menu-surface <MenubarTrigger className="docs-menu-trigger">Affichage</MenubarTrigger>
> <MenubarContent {...DOCS_MENUBAR_CONTENT_PROPS} data-docs-menu-surface>
<MenubarItem disabled className="text-xs text-muted-foreground"> <MenubarItem disabled className="text-muted-foreground">
Mode (bientôt) Bientôt disponible
</MenubarItem> </MenubarItem>
<MenubarSeparator />
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Taille de page
</div>
{PAGE_FORMATS.map((format) => (
<MenubarItem
key={format.id}
onClick={() => onPageFormatChange(format.id)}
className={cn(pageFormatId === format.id && "bg-accent")}
>
{format.label}
<span className="ml-auto text-xs text-muted-foreground">
{format.widthMm} × {format.heightMm} mm
</span>
</MenubarItem>
))}
<MenubarSeparator />
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Zoom
</div>
{[50, 75, 100, 125, 150, 200].map((value) => (
<MenubarItem
key={value}
onClick={() => onZoomChange(value)}
className={cn(zoom === value && "bg-accent")}
>
{value}%
</MenubarItem>
))}
</MenubarContent> </MenubarContent>
</MenubarMenu> </MenubarMenu>
) )}
}
return ( {OTHER_MENU_LABELS.map((label) => (
<MenubarMenu key={label}> <MenubarMenu key={label}>
<MenubarTrigger className="docs-menu-trigger">{label}</MenubarTrigger> <MenubarTrigger className="docs-menu-trigger">{label}</MenubarTrigger>
<MenubarContent <MenubarContent
@ -142,8 +103,7 @@ export function DocsMenubar({
</MenubarItem> </MenubarItem>
</MenubarContent> </MenubarContent>
</MenubarMenu> </MenubarMenu>
) ))}
})}
</Menubar> </Menubar>
) )
} }

View File

@ -1,24 +1,62 @@
"use client" "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 { 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 { cn } from "@/lib/utils"
import { focusEditorAtPointer } from "@/lib/drive/focus-editor-at-pointer" 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 /** Total layout height inside ProseMirror (blocks + flow spacers). */
/** Actual block layout height — ignores CSS min-height on ProseMirror (page stack). */
function measureProseContentHeight(prose: HTMLElement): number { function measureProseContentHeight(prose: HTMLElement): number {
if (prose.childElementCount === 0) { const metrics = readPageFlowMetrics(prose)
return 0 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 let maxBottom = 0
for (const child of prose.children) { for (const child of prose.children) {
const el = child as HTMLElement const el = child as HTMLElement
maxBottom = Math.max(maxBottom, el.offsetTop + el.offsetHeight) maxBottom = Math.max(maxBottom, el.offsetTop + el.offsetHeight)
} }
return maxBottom
return Math.max(simulated, maxBottom)
} }
function DocsPageViewInner({ function DocsPageViewInner({
@ -26,85 +64,217 @@ function DocsPageViewInner({
pageLayout, pageLayout,
zoom, zoom,
editable, editable,
showLayout,
showNonPrintableChars,
editorMode,
canvasRef: canvasRefProp,
onPageCountChange, onPageCountChange,
onNarrowViewportChange,
onCanvasHeightChange,
onRegionContentChange,
onPageSetupChange,
onRegionEditorChange,
}: { }: {
editor: Editor editor: Editor
pageLayout: DocPageLayout pageLayout: DocPageLayout
zoom: number zoom: number
editable: boolean editable: boolean
showLayout: boolean
showRuler: boolean
showNonPrintableChars: boolean
editorMode: "edit" | "suggest" | "view"
canvasRef?: RefObject<HTMLDivElement | null>
onPageCountChange?: (count: number) => void onPageCountChange?: (count: number) => void
onNarrowViewportChange?: (narrow: boolean) => void
onCanvasHeightChange?: (height: number) => void
onRegionContentChange?: (
region: DocsHeaderFooterRegion,
content: Record<string, unknown>,
meta: { pageIndex: number; contentHeightPx: number }
) => void
onPageSetupChange?: (patch: Partial<DocPageSetup>) => void
onRegionEditorChange?: (editor: Editor | null) => void
}) { }) {
const pageWidth = pageLayout.widthPx const pageWidth = pageLayout.widthPx
const pageHeight = pageLayout.heightPx const pageHeight = pageLayout.heightPx
const margins = pageLayout.marginsPx const margins = pageLayout.marginsPx
const canvasRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const [pageCount, setPageCount] = useState(1) const [pageCount, setPageCount] = useState(1)
const [narrowViewport, setNarrowViewport] = useState(false) const [narrowViewport, setNarrowViewport] = useState(false)
const [editingTarget, setEditingTarget] = useState<DocsHeaderFooterEditTarget>(null)
const [pageRegionHeights, setPageRegionHeights] = useState<
Record<string, number>
>({})
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<HTMLDivElement>(null)
const canvasRef = canvasRefProp ?? localCanvasRef
const contentRef = useRef<HTMLDivElement>(null)
const onPageCountChangeRef = useRef(onPageCountChange) const onPageCountChangeRef = useRef(onPageCountChange)
onPageCountChangeRef.current = onPageCountChange onPageCountChangeRef.current = onPageCountChange
const scale = zoom / 100 const scale = docsZoomToScale(zoom)
const scaledWidth = pageWidth * scale 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(() => { useEffect(() => {
const canvas = canvasRef.current const canvas = canvasRef.current
if (!canvas) return if (!canvas) return
const syncViewport = () => { const syncViewport = () => {
setNarrowViewport(canvas.clientWidth < scaledWidth) const narrow = canvas.clientWidth < scaledWidth
setNarrowViewport(narrow)
onNarrowViewportChange?.(narrow)
onCanvasHeightChange?.(canvas.clientHeight)
} }
syncViewport() syncViewport()
const ro = new ResizeObserver(syncViewport) const ro = new ResizeObserver(syncViewport)
ro.observe(canvas) ro.observe(canvas)
return () => ro.disconnect() return () => ro.disconnect()
}, [scaledWidth]) }, [onCanvasHeightChange, onNarrowViewportChange, scaledWidth, canvasRef])
useEffect(() => { useEffect(() => {
if (!showLayout) return
const surface = contentRef.current const surface = contentRef.current
if (!surface) return if (!surface) return
let rafId = 0 let debounceId: ReturnType<typeof setTimeout> | null = null
let cancelled = false
const measure = () => { const measurePageCount = () => {
const prose = surface.querySelector(".ProseMirror") as HTMLElement | null const prose = surface.querySelector(".ProseMirror") as HTMLElement | null
if (!prose) return if (!prose) return
const contentHeight = measureProseContentHeight(prose) const contentHeight = measureProseContentHeight(prose)
const paddedHeight = margins.top + margins.bottom + contentHeight const count = computePageCount(contentHeight, metrics)
const count = Math.max(1, Math.ceil(paddedHeight / pageHeight))
setPageCount((prev) => (prev === count ? prev : count)) setPageCount((prev) => (prev === count ? prev : count))
} }
const scheduleMeasure = () => { const runLayoutPasses = (passesLeft: number) => {
if (rafId) cancelAnimationFrame(rafId) if (cancelled || editor.isDestroyed) return
rafId = requestAnimationFrame(measure) requestAnimationFrame(() => {
if (cancelled || editor.isDestroyed) return
const changed = applyPageFlowLayout(editor)
if (changed && passesLeft > 1) {
runLayoutPasses(passesLeft - 1)
return
}
measurePageCount()
})
} }
scheduleMeasure() let flushPending = false
const prose = surface.querySelector(".ProseMirror") as HTMLElement | null const scheduleLayout = () => {
const ro = prose ? new ResizeObserver(scheduleMeasure) : null if (!flushPending) {
if (prose && ro) ro.observe(prose) 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) editor.on("transaction", onTransaction)
return () => { return () => {
if (rafId) cancelAnimationFrame(rafId) cancelled = true
ro?.disconnect() if (debounceId) clearTimeout(debounceId)
editor.off("transaction", onTransaction) editor.off("transaction", onTransaction)
} }
}, [margins.bottom, margins.top, pageHeight, editor]) }, [editor, metrics, pageRegionHeights, showLayout])
useEffect(() => { useEffect(() => {
onPageCountChangeRef.current?.(pageCount) onPageCountChangeRef.current?.(pageCount)
}, [pageCount]) }, [pageCount])
const stackHeight = pageCount * pageHeight + (pageCount - 1) * PAGE_GAP_PX const stackHeight = computeStackHeight(pageCount, pageHeight)
const innerMinHeight = Math.max(pageHeight - margins.top - margins.bottom, stackHeight - margins.top - margins.bottom) const proseMinHeight = computeProseMinHeight(pageCount, metrics)
const scaledHeight = stackHeight * scale
const verticalPadding = narrowViewport ? 32 : 64 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 textAreaBorderCss = pageLayout.textAreaBorderCss
const sheetBorderCss = pageLayout.sheetBorderCss const sheetBorderCss = pageLayout.sheetBorderCss
const pageBackground = pageLayout.pageColor const pageBackground = pageLayout.pageColor
@ -161,34 +331,43 @@ function DocsPageViewInner({
</> </>
) )
const bodyDimmed = editingTarget != null
return ( return (
<div ref={canvasRef} className={cn(
"ultidrive-docs-canvas h-full min-h-0 overflow-auto",
showLayout ? "bg-[#f9fbfd] dark:bg-[#202124]" : "bg-white dark:bg-background",
showNonPrintableChars && "docs-show-non-printable",
editorMode === "suggest" && "docs-editor-mode-suggest",
editorMode === "view" && "docs-editor-mode-view"
)}>
<div <div
ref={canvasRef} className="mx-auto"
className="ultidrive-docs-canvas min-h-0 flex-1 overflow-auto bg-[#f9fbfd] dark:bg-[#202124]"
>
<div
className={cn("mx-auto", narrowViewport ? "pb-8 pt-0" : "py-8")}
style={{ style={{
width: scaledWidth, width: scaledWidth,
minHeight: scaledHeight + verticalPadding, paddingTop: verticalPaddingTop,
paddingBottom: verticalPaddingBottom,
minHeight: (showLayout ? scaledHeight : proseMinHeight + margins.top + margins.bottom) + verticalPaddingTop + verticalPaddingBottom,
}} }}
> >
<div <div
className="relative mx-auto" className="relative mx-auto overflow-hidden"
style={{ style={{ width: scaledWidth, height: showLayout ? scaledHeight : undefined }}
width: scaledWidth,
height: scaledHeight,
}}
> >
<div <div
className="absolute left-1/2 top-0 origin-top -translate-x-1/2" data-docs-page-stack
className="absolute left-1/2 top-0 -translate-x-1/2 overflow-hidden"
style={{ style={{
width: pageWidth, width: pageWidth,
height: stackHeight, height: stackHeight,
transform: `scale(${scale})`, transform: `scale(${scale})`,
transformOrigin: "top center",
}} }}
> >
{Array.from({ length: pageCount }, (_, index) => ( {showLayout
? Array.from({ length: pageCount }, (_, index) => {
const pageTop = index * (pageHeight + DOCS_PAGE_GAP_PX)
return (
<div <div
key={index} key={index}
className={cn( className={cn(
@ -196,7 +375,7 @@ function DocsPageViewInner({
sheetBorderCss && "ultidrive-docs-page--imported-border" sheetBorderCss && "ultidrive-docs-page--imported-border"
)} )}
style={{ style={{
top: index * (pageHeight + PAGE_GAP_PX), top: pageTop,
width: pageWidth, width: pageWidth,
height: pageHeight, height: pageHeight,
backgroundColor: pageBackground, backgroundColor: pageBackground,
@ -215,18 +394,20 @@ function DocsPageViewInner({
> >
{renderPageBackground(index)} {renderPageBackground(index)}
</div> </div>
))} )
})
: null}
{textAreaBorderCss {showLayout && textAreaBorderCss
? Array.from({ length: pageCount }, (_, index) => ( ? Array.from({ length: pageCount }, (_, index) => (
<div <div
key={`text-border-${index}`} key={`text-border-${index}`}
className="pointer-events-none absolute z-[5] box-border" className="pointer-events-none absolute z-[5] box-border"
style={{ style={{
top: index * (pageHeight + PAGE_GAP_PX) + margins.top, top: index * (pageHeight + DOCS_PAGE_GAP_PX) + effectiveMargins.top,
left: margins.left, left: effectiveMargins.left,
width: pageWidth - margins.left - margins.right, width: pageWidth - effectiveMargins.left - effectiveMargins.right,
height: pageHeight - margins.top - margins.bottom, height: pageHeight - effectiveMargins.top - effectiveMargins.bottom,
borderTop: textAreaBorderCss.top ?? "none", borderTop: textAreaBorderCss.top ?? "none",
borderRight: textAreaBorderCss.right ?? "none", borderRight: textAreaBorderCss.right ?? "none",
borderBottom: textAreaBorderCss.bottom ?? "none", borderBottom: textAreaBorderCss.bottom ?? "none",
@ -237,16 +418,114 @@ function DocsPageViewInner({
)) ))
: null} : null}
{showLayout ? (
<DocsBodyMarginMasks
pageCount={pageCount}
pageLayout={pageLayout}
pageWidth={pageWidth}
pageHeight={pageHeight}
pageColor={pageBackground}
pageRegionHeights={pageRegionHeights}
/>
) : null}
{showLayout
? Array.from({ length: pageCount }, (_, index) => {
const pageTop = index * (pageHeight + DOCS_PAGE_GAP_PX)
return (
<div key={`hf-${index}`}>
<DocsHeaderFooterBand
region="header"
pageLayout={pageLayout}
pageIndex={index}
pageTop={pageTop}
pageWidth={pageWidth}
pageHeight={pageHeight}
margins={margins}
editable={editable}
editingTarget={editingTarget}
onStartEdit={startRegionEdit}
onStopEdit={stopRegionEdit}
onContentChange={(region, content, meta) =>
onRegionContentChange?.(region, content, meta)
}
onPageSetupChange={(patch) => onPageSetupChange?.(patch)}
onRegionHeightMeasure={handleRegionHeightMeasure}
onRegionEditorReady={
editingTarget?.region === "header" &&
editingTarget.pageIndex === index
? handleRegionEditorReady
: undefined
}
/>
<DocsHeaderFooterBand
region="footer"
pageLayout={pageLayout}
pageIndex={index}
pageTop={pageTop}
pageWidth={pageWidth}
pageHeight={pageHeight}
margins={margins}
editable={editable}
editingTarget={editingTarget}
onStartEdit={startRegionEdit}
onStopEdit={stopRegionEdit}
onContentChange={(region, content, meta) =>
onRegionContentChange?.(region, content, meta)
}
onPageSetupChange={(patch) => onPageSetupChange?.(patch)}
onRegionHeightMeasure={handleRegionHeightMeasure}
onRegionEditorReady={
editingTarget?.region === "footer" &&
editingTarget.pageIndex === index
? handleRegionEditorReady
: undefined
}
/>
</div>
)
})
: null}
{bodyDimmed ? (
<div
className="pointer-events-none absolute inset-0 z-[12] bg-[#e8eaed]/40"
style={{ height: stackHeight }}
aria-hidden
/>
) : null}
<div <div
ref={contentRef} ref={contentRef}
className="ultidrive-docs-editor-surface relative z-10" className={cn(
"ultidrive-docs-editor-surface relative",
showLayout && "ultidrive-docs-editor-surface--paginated",
!showLayout && "ultidrive-docs-editor-surface--compact",
bodyDimmed && "ultidrive-docs-editor-surface--dimmed"
)}
style={{ style={{
padding: `${margins.top}px ${margins.right}px ${margins.bottom}px ${margins.left}px`, padding: `${effectiveMargins.top}px ${effectiveMargins.right}px ${effectiveMargins.bottom}px ${effectiveMargins.left}px`,
minHeight: stackHeight, height: showLayout ? stackHeight : undefined,
["--docs-prose-min-height" as string]: `${innerMinHeight}px`, minHeight: showLayout
? undefined
: proseMinHeight + effectiveMargins.top + effectiveMargins.bottom,
["--docs-stack-height" as string]: `${stackHeight}px`,
["--docs-prose-min-height" as string]: `${proseMinHeight}px`,
["--docs-body-area-h" as string]: `${metrics.bodyAreaHeight}px`,
["--docs-inter-page-spacer" as string]: `${metrics.interPageSpacer}px`,
}} }}
onMouseDown={(event) => { onMouseDown={(event) => {
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 const target = event.target as HTMLElement
if (target.closest(".ProseMirror")) return if (target.closest(".ProseMirror")) return
event.preventDefault() event.preventDefault()
@ -270,12 +549,16 @@ export const DocsPageView = memo(DocsPageViewInner)
export function DocsStatusBar({ export function DocsStatusBar({
pageLayout, pageLayout,
pageCount, pageCount,
currentPage = 1,
className, className,
}: { }: {
pageLayout: DocPageLayout pageLayout: DocPageLayout
pageCount: number pageCount: number
currentPage?: number
className?: string className?: string
}) { }) {
const pageLabel = Math.min(Math.max(1, currentPage), Math.max(1, pageCount))
return ( return (
<div <div
className={cn( className={cn(
@ -283,7 +566,9 @@ export function DocsStatusBar({
className className
)} )}
> >
<span>Page 1 sur {pageCount}</span> <span>
Page {pageLabel} sur {pageCount}
</span>
<span> <span>
{pageLayout.format.label} ({pageLayout.format.widthMm} × {pageLayout.format.heightMm} mm) {pageLayout.format.label} ({pageLayout.format.widthMm} × {pageLayout.format.heightMm} mm)
</span> </span>

View File

@ -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<string, unknown> | 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<string, unknown>) => void
onBlur?: () => void
onEditorReady?: (editor: Editor | null) => void
onContentHeightChange?: (height: number) => void
autoFocus?: boolean
}) {
const syncingRef = useRef(false)
const rootRef = useRef<HTMLDivElement>(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<string, unknown>)
},
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 (
<div
ref={rootRef}
className={cn("docs-region-editor-root", maxHeightPx == null && "docs-region-editor-root--grow")}
style={minHeightPx != null ? { minHeight: minHeightPx } : undefined}
>
<EditorContent
editor={editor}
className={cn("docs-region-editor", maxHeightPx == null ? "docs-region-editor--grow" : "h-full", className)}
style={
maxHeightPx != null
? { height: maxHeightPx, maxHeight: maxHeightPx }
: undefined
}
/>
</div>
)
}

View File

@ -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(
<div
className="docs-ruler-margin-tooltip pointer-events-none fixed z-[200] rounded px-2 py-1 text-xs font-medium tabular-nums text-white shadow-md"
style={{
left: tooltip.x + 12,
top: tooltip.y + 12,
}}
role="status"
aria-live="polite"
>
{tooltip.label}
</div>,
document.body
)
})
/** Blue downward triangle (margin / left indent). */
export const DocsRulerTriangleMarker = memo(function DocsRulerTriangleMarker({
left,
className,
}: {
left: number
className?: string
}) {
return (
<div className={className} style={{ left }} aria-hidden>
<svg width="8" height="6" viewBox="0 0 8 6" className="block">
<path d="M0 0 L8 0 L4 6 Z" fill="#1a73e8" />
</svg>
</div>
)
})
/** Blue rectangle on horizontal ruler (first-line indent). */
export const DocsRulerFirstLineMarker = memo(function DocsRulerFirstLineMarker({
left,
}: {
left: number
}) {
return (
<div
className="pointer-events-none absolute top-0 h-[5px] w-[6px] -translate-x-1/2 rounded-[1px] bg-[#1a73e8]"
style={{ left }}
aria-hidden
/>
)
})
/** Blue upward triangle on vertical ruler (top margin). */
export const DocsRulerUpTriangleMarker = memo(function DocsRulerUpTriangleMarker({
top,
}: {
top: number
}) {
return (
<div
className="pointer-events-none absolute left-1/2 -translate-x-1/2"
style={{ top }}
aria-hidden
>
<svg width="6" height="8" viewBox="0 0 6 8" className="block">
<path d="M0 8 L6 8 L3 0 Z" fill="#1a73e8" />
</svg>
</div>
)
})
/** Blue downward triangle on vertical ruler (bottom margin). */
export const DocsRulerDownTriangleMarker = memo(function DocsRulerDownTriangleMarker({
top,
}: {
top: number
}) {
return (
<div
className="absolute left-1/2 -translate-x-1/2 -translate-y-full"
style={{ top }}
aria-hidden
>
<svg width="6" height="8" viewBox="0 0 6 8" className="block">
<path d="M0 0 L6 0 L3 8 Z" fill="#1a73e8" />
</svg>
</div>
)
})
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<HTMLDivElement>) => void
children: ReactNode
}) {
return (
<div
role="slider"
aria-label={ariaLabel}
aria-disabled={disabled || undefined}
className={cn(
"docs-ruler-drag-handle absolute touch-none select-none",
axis === "horizontal"
? "bottom-0 -translate-x-1/2 cursor-ew-resize"
: "left-1/2 -translate-x-1/2 cursor-ns-resize",
disabled ? "pointer-events-none" : "pointer-events-auto",
className
)}
style={style}
onPointerDown={disabled ? undefined : onPointerDown}
>
<div
className={cn(
"flex items-center justify-center",
axis === "horizontal" ? "h-5 w-4 -mb-0.5" : "h-4 w-5"
)}
>
{children}
</div>
</div>
)
})
export function useRulerPointerDrag({
rulerRef,
axis,
disabled,
onDrag,
onDragEnd,
}: {
rulerRef: React.RefObject<HTMLElement | null>
axis: "horizontal" | "vertical"
disabled?: boolean
onDrag: (pagePx: number, clientX: number, clientY: number) => void
onDragEnd: () => void
}) {
const draggingRef = useRef(false)
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
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 }
}

View File

@ -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<HTMLDivElement | null>
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 (
<div
className="docs-toolbar-ruler-row flex shrink-0 bg-transparent"
style={{ height: DOCS_HORIZONTAL_RULER_HEIGHT_PX }}
>
<div
className="flex shrink-0 items-end justify-center border-r border-[#dadce0]/80 bg-transparent pb-0.5 dark:border-border/80"
style={{
width: DOCS_VERTICAL_RULER_WIDTH_PX,
height: DOCS_HORIZONTAL_RULER_HEIGHT_PX,
}}
>
<button
type="button"
className={cn(
"pointer-events-auto flex size-7 items-center justify-center rounded-full border border-[#dadce0] bg-white text-[#5f6368] shadow-sm transition-colors hover:bg-[#e8eaed] dark:border-border dark:bg-background dark:text-muted-foreground dark:hover:bg-muted",
outlineExpanded && "border-[#1a73e8] text-[#1a73e8]"
)}
aria-label="Afficher le plan du document"
aria-pressed={outlineExpanded}
onClick={onToggleOutline}
>
<ListTree className="size-3.5" />
</button>
</div>
<div
ref={rulerTrackRef}
className="relative min-w-0 flex-1 overflow-visible"
style={{
height: DOCS_HORIZONTAL_RULER_HEIGHT_PX,
paddingRight: rulerSync.canvasScrollbarWidth,
}}
>
<div
className="mx-auto"
style={{
width: scaledPageWidth,
transform: rulerSync.canvasScrollLeft
? `translateX(${-rulerSync.canvasScrollLeft}px)`
: undefined,
}}
>
<DocsHorizontalRuler
pageLayout={pageLayout}
scale={scale}
indents={rulerSync.indents}
editable={editable}
onMarginDragStart={onMarginDragStart}
onMarginDrag={onMarginDrag}
onMarginDragEnd={onMarginDragEnd}
/>
</div>
</div>
</div>
)
}
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 (
<div
className={cn(
"docs-rulers-left-rail absolute bottom-0 left-0 top-0 z-20 overflow-visible bg-transparent",
!editable && "pointer-events-none"
)}
style={{ width: DOCS_VERTICAL_RULER_WIDTH_PX }}
>
<div className="pointer-events-auto absolute left-0" style={{ top: rulerSync.pageTopInViewport }}>
<DocsVerticalRuler
pageLayout={pageLayout}
scale={scale}
editable={editable}
onMarginDragStart={onMarginDragStart}
onMarginDrag={onMarginDrag}
onMarginDragEnd={onMarginDragEnd}
/>
</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
"use client" "use client"
import { memo, useCallback, useMemo, useRef, useState } from "react" import { memo, useCallback, useMemo, useState } from "react"
import { Icon } from "@iconify/react" import { Icon } from "@iconify/react"
import type { Editor } from "@tiptap/react" import type { Editor } from "@tiptap/react"
import { import {
@ -11,7 +11,6 @@ import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Bold, Bold,
Image as ImageIcon,
Italic, Italic,
Link2, Link2,
List, List,
@ -55,6 +54,12 @@ import {
DOCS_FONT_FAMILIES, DOCS_FONT_FAMILIES,
type DocsFontFamilyName, type DocsFontFamilyName,
} from "@/lib/drive/docs-font-family" } 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 { useDocsToolbarState } from "@/lib/drive/use-docs-toolbar-state"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -125,6 +130,7 @@ function DocsToolbarInner({
showChromeToggle, showChromeToggle,
chromeCollapsed, chromeCollapsed,
onToggleChromeCollapsed, onToggleChromeCollapsed,
embedded,
}: { }: {
editor: Editor | null editor: Editor | null
disabled?: boolean disabled?: boolean
@ -135,24 +141,13 @@ function DocsToolbarInner({
showChromeToggle?: boolean showChromeToggle?: boolean
chromeCollapsed?: boolean chromeCollapsed?: boolean
onToggleChromeCollapsed?: () => void onToggleChromeCollapsed?: () => void
/** Rendered inside DocsEditorWorkspace shell (no outer docs-toolbar-shell). */
embedded?: boolean
}) { }) {
const imageInputRef = useRef<HTMLInputElement>(null)
const [linkOpen, setLinkOpen] = useState(false) const [linkOpen, setLinkOpen] = useState(false)
const [linkUrl, setLinkUrl] = useState("") const [linkUrl, setLinkUrl] = useState("")
const toolbarState = useDocsToolbarState(editor) const toolbarState = useDocsToolbarState(editor)
const graphicSelected = readGraphicToolbarActive(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 applyLink = useCallback(() => { const applyLink = useCallback(() => {
if (!editor) return if (!editor) return
@ -473,16 +468,18 @@ function DocsToolbarInner({
), ),
}, },
{ {
id: "insert-image", id: "insert-graphic",
sepAfter: true, sepAfter: true,
node: ( node: (
<ToolbarIconBtn <>
disabled={disabled} <DocsGraphicInsertMenu editor={editor} disabled={disabled} />
label="Insérer une image" {graphicSelected ? (
onClick={() => imageInputRef.current?.click()} <>
> <DocsGraphicLayoutMenu editor={editor} disabled={disabled} />
<ImageIcon className="size-4" /> <DocsGraphicOptionsPanel editor={editor} disabled={disabled} />
</ToolbarIconBtn> </>
) : null}
</>
), ),
}, },
{ {
@ -597,6 +594,7 @@ function DocsToolbarInner({
onZoomChange, onZoomChange,
spellcheck, spellcheck,
onToggleSpellcheck, onToggleSpellcheck,
graphicSelected,
linkOpen, linkOpen,
linkUrl, linkUrl,
applyLink, applyLink,
@ -611,13 +609,7 @@ function DocsToolbarInner({
const visibleSegments = segments.slice(0, visibleCount) const visibleSegments = segments.slice(0, visibleCount)
const overflowSegments = segments.slice(visibleCount) const overflowSegments = segments.slice(visibleCount)
return ( const toolbarRow = (
<div
className={cn(
"docs-toolbar-shell shrink-0",
chromeCollapsed && "docs-toolbar-shell--collapsed"
)}
>
<div <div
ref={containerRef} ref={containerRef}
className="docs-toolbar relative flex items-center gap-0 overflow-hidden px-1.5 py-0.5" className="docs-toolbar relative flex items-center gap-0 overflow-hidden px-1.5 py-0.5"
@ -693,19 +685,19 @@ function DocsToolbarInner({
</ToolbarIconBtn> </ToolbarIconBtn>
</> </>
) : null} ) : null}
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) insertImage(file)
e.target.value = ""
}}
/>
</div> </div>
)
if (embedded) return toolbarRow
return (
<div
className={cn(
"docs-toolbar-shell shrink-0",
chromeCollapsed && "docs-toolbar-shell--collapsed"
)}
>
{toolbarRow}
</div> </div>
) )
} }

View File

@ -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<HTMLDivElement>(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<HTMLDivElement>) => void) =>
(event: React.PointerEvent<HTMLDivElement>) => {
onMarginDragStart?.(side)
handler(event)
}
return (
<div
ref={rulerRef}
data-docs-ruler="vertical"
data-docs-ruler-scale={scale}
className="docs-vertical-ruler relative overflow-visible border-r border-[#dadce0] bg-white dark:border-border dark:bg-background"
style={{
width: DOCS_VERTICAL_RULER_WIDTH_PX,
height: scaledHeight,
}}
>
<div
className="pointer-events-none absolute left-0 right-0 bg-[#f1f3f4] dark:bg-muted/60"
style={{ top: 0, height: s(margins.top) }}
/>
<div
className="pointer-events-none absolute left-0 right-0 bg-[#f1f3f4] dark:bg-muted/60"
style={{
top: s(pageHeight - margins.bottom),
height: s(margins.bottom),
}}
/>
{ticks.map((tick, index) => (
<div
key={`${tick.pos}-${index}`}
className="pointer-events-none absolute right-0 h-px bg-[#80868b] dark:bg-muted-foreground/70"
style={{
top: s(tick.pos),
width: tick.major ? 10 : 5,
}}
/>
))}
{ticks
.filter((tick) => tick.major && tick.label != null)
.map((tick) => (
<span
key={`vlabel-${tick.pos}`}
className="pointer-events-none absolute right-[11px] -translate-y-1/2 text-[9px] leading-none text-[#5f6368] dark:text-muted-foreground"
style={{ top: s(tick.pos) }}
>
{tick.label}
</span>
))}
<DocsRulerDraggableHandle
style={{ top: s(margins.top) }}
axis="vertical"
disabled={!editable}
ariaLabel="Marge haute"
onPointerDown={wrapMarginDown("top", topDrag.onPointerDown)}
>
<DocsRulerUpTriangleMarker top={0} />
</DocsRulerDraggableHandle>
<DocsRulerDraggableHandle
style={{ top: s(pageHeight - margins.bottom) }}
axis="vertical"
disabled={!editable}
ariaLabel="Marge basse"
onPointerDown={wrapMarginDown("bottom", bottomDrag.onPointerDown)}
>
<DocsRulerDownTriangleMarker top={0} />
</DocsRulerDraggableHandle>
</div>
)
}
export const DocsVerticalRuler = memo(DocsVerticalRulerInner)

View File

@ -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 <span className="docs-menu-item-icon">{children}</span>
}
function CheckMenuOption({
checked,
onSelect,
children,
disabled,
}: {
checked: boolean
onSelect: () => void
children: ReactNode
disabled?: boolean
}) {
return (
<MenubarItem
className="docs-menu-item relative pl-8"
disabled={disabled}
onClick={onSelect}
>
{checked ? <Check className="absolute left-2 size-4" aria-hidden /> : null}
{children}
</MenubarItem>
)
}
function ModeMenuOption({
icon: Icon,
label,
description,
selected,
onSelect,
disabled,
}: {
icon: typeof Pencil
label: string
description: string
selected: boolean
onSelect: () => void
disabled?: boolean
}) {
return (
<MenubarItem
className={cn("docs-menu-item docs-menu-mode-item items-start py-2", selected && "bg-accent/60")}
disabled={disabled}
onClick={onSelect}
>
<MenuIcon>
<Icon className="size-4" />
</MenuIcon>
<span className="flex min-w-0 flex-col gap-0.5 leading-tight">
<span className="text-sm font-normal">{label}</span>
<span className="text-xs text-[#5f6368] dark:text-muted-foreground">{description}</span>
</span>
</MenubarItem>
)
}
export function DocsViewMenu({
state,
actions,
disabled,
}: {
state: DocsViewMenuState
actions: DocsViewMenuActions
disabled?: boolean
}) {
return (
<MenubarMenu>
<MenubarTrigger className="docs-menu-trigger">Affichage</MenubarTrigger>
<MenubarContent
{...DOCS_MENUBAR_CONTENT_PROPS}
className="docs-menu-content min-w-[300px] overflow-visible"
data-docs-menu-surface
>
<DocsExclusiveMenuSubRoot>
<DocsExclusiveMenuSub id="mode">
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
<MenuIcon>
<Pencil className="size-4" />
</MenuIcon>
Mode
</MenubarSubTrigger>
<MenubarSubContent
className="docs-menu-content docs-menu-sub-content min-w-[280px] overflow-visible"
data-docs-menu-surface
>
{EDITOR_MODES.map((mode) => (
<ModeMenuOption
key={mode.id}
icon={mode.icon}
label={mode.label}
description={mode.description}
selected={state.editorMode === mode.id}
disabled={disabled}
onSelect={() => actions.onEditorModeChange(mode.id)}
/>
))}
</MenubarSubContent>
</DocsExclusiveMenuSub>
<DocsExclusiveMenuSub id="comments">
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
<MenuIcon>
<MessageSquare className="size-4" />
</MenuIcon>
Commentaires
</MenubarSubTrigger>
<MenubarSubContent
className="docs-menu-content docs-menu-sub-content min-w-[260px] overflow-visible"
data-docs-menu-surface
>
{COMMENTS_OPTIONS.map((option) => (
<CheckMenuOption
key={option.id}
checked={state.commentsDisplay === option.id}
disabled={disabled}
onSelect={() => actions.onCommentsDisplayChange(option.id)}
>
{option.label}
</CheckMenuOption>
))}
</MenubarSubContent>
</DocsExclusiveMenuSub>
<MenubarItem
className="docs-menu-item"
disabled={disabled}
onClick={actions.onToggleOutlineSidebar}
>
<MenuIcon>
<ListTree className="size-4" />
</MenuIcon>
Développer la barre latérale des onglets et sections
<span className="docs-menu-shortcut-sequence ml-auto text-xs text-[#5f6368] dark:text-muted-foreground">
Ctrl+A Ctrl+H
</span>
</MenubarItem>
<MenubarSeparator />
<DocsExclusiveMenuSub id="text-width">
<MenubarSubTrigger className="docs-menu-item" disabled>
<MenuIcon>
<AlignHorizontalSpaceAround className="size-4" />
</MenuIcon>
Largeur du texte
</MenubarSubTrigger>
<MenubarSubContent
className="docs-menu-content docs-menu-sub-content min-w-[220px] overflow-visible"
data-docs-menu-surface
>
<MenubarItem disabled className="docs-menu-item text-muted-foreground">
Bientôt disponible
</MenubarItem>
</MenubarSubContent>
</DocsExclusiveMenuSub>
<MenubarSeparator />
</DocsExclusiveMenuSubRoot>
<MenubarCheckboxItem
className="docs-menu-item docs-menu-checkbox-item"
checked={state.showLayout}
disabled={disabled}
onCheckedChange={() => actions.onToggleShowLayout()}
>
Afficher la mise en page
</MenubarCheckboxItem>
<MenubarCheckboxItem
className="docs-menu-item docs-menu-checkbox-item"
checked={state.showRuler}
disabled={disabled}
onCheckedChange={() => actions.onToggleShowRuler()}
>
Afficher la règle
</MenubarCheckboxItem>
<MenubarCheckboxItem
className="docs-menu-item docs-menu-checkbox-item"
checked={state.showEquationToolbar}
disabled={disabled}
onCheckedChange={() => actions.onToggleShowEquationToolbar()}
>
Afficher la barre d&apos;outils d&apos;équation
</MenubarCheckboxItem>
<MenubarCheckboxItem
className="docs-menu-item docs-menu-checkbox-item"
checked={state.showNonPrintableChars}
disabled={disabled}
onCheckedChange={() => actions.onToggleShowNonPrintableChars()}
>
Afficher les caractères non imprimables
<DocsMenuShortcut shortcutId="view.showNonPrintable" />
</MenubarCheckboxItem>
<MenubarSeparator />
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onFullscreen}>
<MenuIcon>
<Maximize2 className="size-4" />
</MenuIcon>
Plein écran
</MenubarItem>
</MenubarContent>
</MenubarMenu>
)
}

View File

@ -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<DocsPageMarginsPx> | null,
pageWidth: number,
pageHeight: number
): { nextPreview: Partial<DocsPageMarginsPx>; 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<DocPageSetup>,
options?: { immediate?: boolean }
) => void
}) {
const [previewPx, setPreviewPx] = useState<Partial<DocsPageMarginsPx> | null>(null)
const [dragTooltip, setDragTooltip] = useState<DocsRulerDragTooltipState>(null)
const dragBaseRef = useRef<DocsPageMarginsPx>(pageLayout.marginsPx)
/** Sync preview during drag — window pointer events don't flush React state in time. */
const previewRef = useRef<Partial<DocsPageMarginsPx> | 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,
}
}

View File

@ -1,11 +1,11 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { Icon } from "@iconify/react"
import { import {
Building2, Building2,
Copy, Copy,
Eye, Eye,
Globe,
Link2, Link2,
Loader2, Loader2,
Mail, Mail,
@ -30,10 +30,23 @@ import {
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea" 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 { useSearchContacts } from "@/lib/api/hooks/use-contact-queries"
import { useDriveShares, useDriveMutations } from "@/lib/api/hooks/use-drive-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 { displayFileName } from "@/lib/drive/display-file-name"
import { import {
FOLDER_SHARE_PERMISSION_OPTIONS, FOLDER_SHARE_PERMISSION_OPTIONS,
@ -45,29 +58,25 @@ import {
import { import {
NC_SHARE_TYPE, NC_SHARE_TYPE,
SHARE_SECTION_LABELS, SHARE_SECTION_LABELS,
formatShareDate,
groupSharesBySection, groupSharesBySection,
shareAccessLabel, shareAccessLabel,
shareLinkForCopy, shareLinkForCopy,
shareMetaLine,
shareOwnerLabel, shareOwnerLabel,
sharePermissionsLabel,
shareRecipientLabel, shareRecipientLabel,
type DriveShareMode,
type ShareListSection, type ShareListSection,
} from "@/lib/drive/drive-share-types" } from "@/lib/drive/drive-share-types"
import { DriveFileTypeIcon } from "@/lib/drive/drive-file-icon"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store" import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
import { import {
DRIVE_BTN_GHOST, DRIVE_BTN_GHOST,
DRIVE_BTN_PRIMARY, DRIVE_BTN_PRIMARY,
DRIVE_CARD_ACTIVE,
DRIVE_CARD_IDLE,
DRIVE_DIALOG_CONTENT, DRIVE_DIALOG_CONTENT,
DRIVE_DIALOG_DIVIDER, DRIVE_DIALOG_DIVIDER,
DRIVE_DIALOG_FOOTER, DRIVE_DIALOG_FOOTER,
DRIVE_DIALOG_HEADER, DRIVE_DIALOG_HEADER,
DRIVE_DIALOG_OVERLAY, DRIVE_DIALOG_OVERLAY,
DRIVE_FIELD_CLASS, DRIVE_FIELD_CLASS,
DRIVE_LABEL_CLASS,
DRIVE_PANEL_MUTED, DRIVE_PANEL_MUTED,
DRIVE_TEXT_PRIMARY, DRIVE_TEXT_PRIMARY,
DRIVE_TEXT_SECONDARY, DRIVE_TEXT_SECONDARY,
@ -83,59 +92,37 @@ function shareItemLabel(path: string) {
} }
type SharePermissionMode = "viewer" | "editor" | "advanced" type SharePermissionMode = "viewer" | "editor" | "advanced"
type LinkAccessMode = "public" | "internal"
const PERMISSION_MODE_OPTIONS: { const PERMISSION_OPTIONS: {
id: SharePermissionMode id: SharePermissionMode
label: string label: string
description: string
icon: typeof Eye icon: typeof Eye
folderOnly?: boolean folderOnly?: boolean
}[] = [ }[] = [
{ { id: "viewer", label: "Lecteur", icon: Eye },
id: "viewer", { id: "editor", label: "Éditeur", icon: Pencil },
label: "Lecteur", { id: "advanced", label: "Avancé", icon: SlidersHorizontal, folderOnly: true },
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,
},
] ]
const MODE_OPTIONS: { const LINK_ACCESS_OPTIONS: {
id: DriveShareMode id: LinkAccessMode
label: string label: string
description: string description: string
icon: typeof Link2 icon: typeof Globe
}[] = [ }[] = [
{ {
id: "contact", id: "public",
label: "Personne", label: "Lien public",
description: "Partage direct par e-mail ou compte", description: "Toute personne disposant du lien peut consulter l'élément.",
icon: UserRound, icon: Globe,
}, },
{ {
id: "internal", id: "internal",
label: "Lien interne", label: "Lien interne",
description: "Réservé aux utilisateurs inscrits connectés", description: "Réservé aux utilisateurs inscrits et connectés.",
icon: Users, icon: Users,
}, },
{
id: "public",
label: "Lien public",
description: "Accessible à toute personne disposant du lien",
icon: Link2,
},
] ]
function shareSectionIcon(section: ShareListSection) { function shareSectionIcon(section: ShareListSection) {
@ -144,6 +131,29 @@ function shareSectionIcon(section: ShareListSection) {
return Link2 return Link2
} }
function RoleChip({ permissions }: { permissions: number }) {
return (
<span className="inline-flex shrink-0 items-center rounded-full bg-[#e8eaed] px-2 py-0.5 text-[11px] font-medium text-[#3c4043] dark:bg-[#3c4043] dark:text-[#e8eaed]">
{sharePermissionsLabel(permissions)}
</span>
)
}
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({ function ShareEntryRow({
share, share,
onDelete, onDelete,
@ -155,10 +165,9 @@ function ShareEntryRow({
}) { }) {
const url = shareLinkForCopy(share) const url = shareLinkForCopy(share)
const recipient = shareRecipientLabel(share) const recipient = shareRecipientLabel(share)
const owner = shareOwnerLabel(share)
const meta = shareMetaLine(share)
const accessLabel = shareAccessLabel(share) const accessLabel = shareAccessLabel(share)
const isLink = share.share_type === NC_SHARE_TYPE.LINK const isLink = share.share_type === NC_SHARE_TYPE.LINK
const tooltipLines = shareTooltipLines(share)
const copy = async () => { const copy = async () => {
if (!url) return if (!url) return
@ -170,48 +179,38 @@ function ShareEntryRow({
} }
} }
const primaryLine = recipient ?? (url && isLink ? url : accessLabel) const primaryLabel = isLink ? accessLabel : (recipient ?? accessLabel)
return ( return (
<div className={cn(DRIVE_PANEL_MUTED, "rounded-xl px-3 py-3")}> <Tooltip>
<div className="flex items-start gap-3"> <TooltipTrigger asChild>
<div className="min-w-0 flex-1 space-y-1.5"> <div
<div className="flex flex-wrap items-center gap-2"> className={cn(
<span className="inline-flex items-center rounded-md bg-[#e8f0fe] px-2 py-0.5 text-[11px] font-medium text-[#1967d2] dark:bg-[#1a377a]/50 dark:text-[#8ab4f8]"> "group flex items-center gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-[#f1f3f4] dark:hover:bg-[#3c4043]/50",
{accessLabel} tooltipLines.length > 0 && "cursor-default"
</span> )}
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[#e8f0fe] text-[#1967d2] dark:bg-[#1a377a]/50 dark:text-[#8ab4f8]">
{isLink ? (
<Link2 className="h-3.5 w-3.5" aria-hidden />
) : share.share_type === NC_SHARE_TYPE.GROUP ? (
<Building2 className="h-3.5 w-3.5" aria-hidden />
) : (
<UserRound className="h-3.5 w-3.5" aria-hidden />
)}
</div>
<div className="min-w-0 flex-1">
<p className={cn("truncate text-sm", DRIVE_TEXT_PRIMARY)}>{primaryLabel}</p>
</div>
{share.has_password ? ( {share.has_password ? (
<span className="inline-flex items-center gap-1 text-[11px] text-[#5f6368] dark:text-[#9aa0a6]"> <Shield className="h-3.5 w-3.5 shrink-0 text-[#5f6368] dark:text-[#9aa0a6]" aria-label="Mot de passe" />
<Shield className="h-3 w-3" aria-hidden />
Mot de passe
</span>
) : null}
</div>
<p className={cn("text-sm font-medium leading-snug", DRIVE_TEXT_PRIMARY)}>
{primaryLine}
</p>
{url && isLink && recipient ? (
<p className={cn("truncate text-xs", DRIVE_TEXT_SECONDARY)}>{url}</p>
) : null} ) : null}
{owner ? ( <RoleChip permissions={share.permissions} />
<p className={cn("text-xs", DRIVE_TEXT_SECONDARY)}>
Propriétaire · {owner}
</p>
) : null}
<p className={cn("text-xs capitalize", DRIVE_TEXT_SECONDARY)}>{meta}</p> <div className="flex shrink-0 items-center opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{share.note?.trim() ? (
<p className={cn("border-t pt-1.5 text-xs italic", DRIVE_DIALOG_DIVIDER, DRIVE_TEXT_SECONDARY)}>
« {share.note.trim()} »
</p>
) : null}
</div>
<div className="flex shrink-0 items-start gap-0.5">
{url ? ( {url ? (
<Button <Button
type="button" type="button"
@ -221,7 +220,7 @@ function ShareEntryRow({
aria-label="Copier le lien" aria-label="Copier le lien"
onClick={() => void copy()} onClick={() => void copy()}
> >
<Copy className="h-4 w-4" /> <Copy className="h-3.5 w-3.5" />
</Button> </Button>
) : null} ) : null}
<Button <Button
@ -233,11 +232,21 @@ function ShareEntryRow({
disabled={deleting} disabled={deleting}
onClick={onDelete} onClick={onDelete}
> >
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />} {deleting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
</Button> </Button>
</div> </div>
</div> </div>
</div> </TooltipTrigger>
{tooltipLines.length > 0 ? (
<TooltipContent side="top" className="max-w-xs space-y-0.5 text-left">
{tooltipLines.map((line) => (
<p key={line} className="break-all">
{line}
</p>
))}
</TooltipContent>
) : null}
</Tooltip>
) )
} }
@ -257,64 +266,58 @@ function ActiveSharesPanel({
onDeleteShare: (shareId: string) => void onDeleteShare: (shareId: string) => void
}) { }) {
const grouped = useMemo(() => groupSharesBySection(shares), [shares]) const grouped = useMemo(() => groupSharesBySection(shares), [shares])
const sectionOrder: ShareListSection[] = ["links", "people", "groups"] const sectionOrder: ShareListSection[] = ["people", "groups", "links"]
const hasShares = shares.length > 0 const hasShares = shares.length > 0
if (loading) {
return ( return (
<div className="space-y-3 border-t pt-4"> <div className={cn("flex items-center gap-2 py-2 text-sm", DRIVE_TEXT_SECONDARY)}>
<div className="flex items-center justify-between gap-2">
<p className={cn("text-sm font-medium", DRIVE_TEXT_PRIMARY)}>Accès existants</p>
{!loading ? (
<Button
type="button"
variant="ghost"
size="sm"
className={cn(DRIVE_BTN_GHOST, "h-8 px-2 text-xs")}
onClick={onRetry}
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden />
Actualiser
</Button>
) : null}
</div>
{loading ? (
<div className={cn("flex items-center gap-2 text-sm", DRIVE_TEXT_SECONDARY)}>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden /> <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
Chargement des partages Chargement
</div> </div>
) : error ? ( )
<div className={cn(DRIVE_PANEL_MUTED, "space-y-2 rounded-xl px-3 py-3")}> }
<p className={cn("text-sm", DRIVE_TEXT_PRIMARY)}>Impossible de charger les partages existants.</p>
<Button if (error) {
type="button" return (
variant="ghost" <div className="space-y-2 py-2">
size="sm" <p className={cn("text-sm", DRIVE_TEXT_PRIMARY)}>Impossible de charger les partages.</p>
className={cn(DRIVE_BTN_GHOST, "h-8 px-2 text-xs")} <Button type="button" variant="ghost" size="sm" className={cn(DRIVE_BTN_GHOST, "h-8 px-2 text-xs")} onClick={onRetry}>
onClick={onRetry}
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden /> <RefreshCw className="h-3.5 w-3.5" aria-hidden />
Réessayer Réessayer
</Button> </Button>
</div> </div>
) : !hasShares ? ( )
<p className={cn("text-sm", DRIVE_TEXT_SECONDARY)}> }
Aucun partage actif pour cet élément. Créez un lien ou invitez une personne ci-dessus.
</p> if (!hasShares) return null
) : (
<div className="max-h-52 space-y-4 overflow-y-auto pr-0.5"> return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<p className={cn("text-sm font-medium", DRIVE_TEXT_PRIMARY)}>Utilisateurs avec accès</p>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(DRIVE_BTN_GHOST, "h-7 px-2 text-xs")}
onClick={onRetry}
>
<RefreshCw className="h-3 w-3" aria-hidden />
</Button>
</div>
<div className="max-h-44 space-y-3 overflow-y-auto pr-0.5">
{sectionOrder.map((section) => { {sectionOrder.map((section) => {
const items = grouped[section] const items = grouped[section]
if (items.length === 0) return null if (items.length === 0) return null
const SectionIcon = shareSectionIcon(section) const SectionIcon = shareSectionIcon(section)
return ( return (
<div key={section} className="space-y-2"> <div key={section} className="space-y-0.5">
<p className={cn("flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide", DRIVE_TEXT_SECONDARY)}> <p className={cn("flex items-center gap-1 px-2 text-[11px] font-medium uppercase tracking-wide", DRIVE_TEXT_SECONDARY)}>
<SectionIcon className="h-3.5 w-3.5" aria-hidden /> <SectionIcon className="h-3 w-3" aria-hidden />
{SHARE_SECTION_LABELS[section]} {SHARE_SECTION_LABELS[section]}
<span className="font-normal normal-case tracking-normal">({items.length})</span>
</p> </p>
<div className="space-y-2">
{items.map((share) => ( {items.map((share) => (
<ShareEntryRow <ShareEntryRow
key={share.id} key={share.id}
@ -324,88 +327,34 @@ function ActiveSharesPanel({
/> />
))} ))}
</div> </div>
</div>
) )
})} })}
</div> </div>
)}
</div> </div>
) )
} }
function SharePermissionsPanel({ function AdvancedPermissionsPanel({
isFolder,
permissionMode,
folderPermissions, folderPermissions,
onPermissionModeChange,
onFolderPermissionChange, onFolderPermissionChange,
}: { }: {
isFolder: boolean
permissionMode: SharePermissionMode
folderPermissions: FolderSharePermissions folderPermissions: FolderSharePermissions
onPermissionModeChange: (mode: SharePermissionMode) => void
onFolderPermissionChange: (id: FolderSharePermissionId, checked: boolean) => void onFolderPermissionChange: (id: FolderSharePermissionId, checked: boolean) => void
}) { }) {
const advancedPermissionBits = folderPermissionsToBitmask(folderPermissions) const advancedPermissionBits = folderPermissionsToBitmask(folderPermissions)
const modeOptions = PERMISSION_MODE_OPTIONS.filter((option) => isFolder || !option.folderOnly)
return ( return (
<div className="space-y-2.5"> <div className={cn(DRIVE_PANEL_MUTED, "mt-2 space-y-2 px-3 py-2.5")}>
<div className={cn("grid gap-2", isFolder ? "grid-cols-3" : "grid-cols-2")}>
{modeOptions.map((option) => {
const IconComponent = option.icon
const selected = permissionMode === option.id
return (
<button
key={option.id}
type="button"
onClick={() => onPermissionModeChange(option.id)}
className={cn(
"flex cursor-pointer flex-col items-start gap-1 rounded-xl border px-3 py-3 text-left transition-colors",
selected ? DRIVE_CARD_ACTIVE : DRIVE_CARD_IDLE
)}
>
<span className="flex items-center gap-2">
<IconComponent
className={cn(
"h-4 w-4",
selected ? "text-[#1967d2] dark:text-[#8ab4f8]" : DRIVE_TEXT_SECONDARY
)}
aria-hidden
/>
<span
className={cn(
"text-sm font-medium",
selected ? "text-[#1967d2] dark:text-[#8ab4f8]" : DRIVE_TEXT_PRIMARY
)}
>
{option.label}
</span>
</span>
<span className={cn("text-xs", DRIVE_TEXT_SECONDARY)}>{option.description}</span>
</button>
)
})}
</div>
{isFolder && permissionMode === "advanced" ? (
<div className={cn(DRIVE_PANEL_MUTED, "space-y-2 px-3 py-3")}>
{FOLDER_SHARE_PERMISSION_OPTIONS.map((option) => { {FOLDER_SHARE_PERMISSION_OPTIONS.map((option) => {
const checked = folderPermissions[option.id]
const checkboxId = `drive-share-perm-${option.id}` const checkboxId = `drive-share-perm-${option.id}`
return ( return (
<div key={option.id} className="flex items-center gap-3 rounded-lg py-1"> <div key={option.id} className="flex items-center gap-2.5 py-0.5">
<Checkbox <Checkbox
id={checkboxId} id={checkboxId}
checked={checked} checked={folderPermissions[option.id]}
onCheckedChange={(value) => onCheckedChange={(value) => onFolderPermissionChange(option.id, value === true)}
onFolderPermissionChange(option.id, value === true)
}
/> />
<Label <Label htmlFor={checkboxId} className={cn("cursor-pointer text-sm font-normal", DRIVE_TEXT_PRIMARY)}>
htmlFor={checkboxId}
className={cn("cursor-pointer text-sm font-normal", DRIVE_TEXT_PRIMARY)}
>
{option.label} {option.label}
</Label> </Label>
</div> </div>
@ -413,8 +362,7 @@ function SharePermissionsPanel({
})} })}
{!folderPermissions.viewContent && folderPermissions.addFiles ? ( {!folderPermissions.viewContent && folderPermissions.addFiles ? (
<p className={cn("border-t pt-2 text-xs leading-relaxed", DRIVE_DIALOG_DIVIDER, DRIVE_TEXT_SECONDARY)}> <p className={cn("border-t pt-2 text-xs leading-relaxed", DRIVE_DIALOG_DIVIDER, DRIVE_TEXT_SECONDARY)}>
Dépôt uniquement : les visiteurs pourront ajouter des fichiers sans voir le contenu Dépôt uniquement : les visiteurs pourront ajouter des fichiers sans voir le contenu existant.
existant du dossier.
</p> </p>
) : null} ) : null}
{advancedPermissionBits === 0 ? ( {advancedPermissionBits === 0 ? (
@ -423,8 +371,54 @@ function SharePermissionsPanel({
</p> </p>
) : null} ) : null}
</div> </div>
) : null} )
</div> }
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 (
<Select value={value} onValueChange={(v) => onChange(v as SharePermissionMode)}>
<SelectTrigger
size="sm"
className={cn(
"h-9 min-w-[132px] shrink-0 border-[#dadce0] bg-transparent px-3 text-sm shadow-none dark:border-[#5f6368]/40",
className
)}
>
<SelectValue>
<span className="flex items-center gap-1.5">
<SelectedIcon className="h-3.5 w-3.5 shrink-0" aria-hidden />
{selected.label}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
{options.map((option) => {
const Icon = option.icon
return (
<SelectItem key={option.id} value={option.id}>
<span className="flex items-center gap-2">
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
{option.label}
</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
) )
} }
@ -432,7 +426,7 @@ export function ShareDialog() {
const path = useDriveUIStore((s) => s.sharePath) const path = useDriveUIStore((s) => s.sharePath)
const shareItemType = useDriveUIStore((s) => s.shareItemType) const shareItemType = useDriveUIStore((s) => s.shareItemType)
const setSharePath = useDriveUIStore((s) => s.setSharePath) const setSharePath = useDriveUIStore((s) => s.setSharePath)
const [mode, setMode] = useState<DriveShareMode>("public") const [linkAccessMode, setLinkAccessMode] = useState<LinkAccessMode>("public")
const [permissionMode, setPermissionMode] = useState<SharePermissionMode>("viewer") const [permissionMode, setPermissionMode] = useState<SharePermissionMode>("viewer")
const [folderPermissions, setFolderPermissions] = useState<FolderSharePermissions>( const [folderPermissions, setFolderPermissions] = useState<FolderSharePermissions>(
() => folderPermissionsFromRole("viewer") () => folderPermissionsFromRole("viewer")
@ -453,25 +447,14 @@ export function ShareDialog() {
const itemLabel = useMemo(() => (path ? shareItemLabel(path) : ""), [path]) const itemLabel = useMemo(() => (path ? shareItemLabel(path) : ""), [path])
const isFolder = shareItemType === "directory" const isFolder = shareItemType === "directory"
const selectedMode = MODE_OPTIONS.find((m) => m.id === mode) ?? MODE_OPTIONS[0] const selectedLinkAccess = LINK_ACCESS_OPTIONS.find((o) => o.id === linkAccessMode) ?? LINK_ACCESS_OPTIONS[0]
const LinkAccessIcon = selectedLinkAccess.icon
const filePreview = useMemo((): DriveFileInfo | null => { const showContactExtras = contactEmail.trim().length > 0
if (!path) return null const hasValidContactEmail = contactEmail.trim().includes("@")
return {
path,
name: itemLabel,
type: shareItemType ?? "file",
size: 0,
mime_type: "",
last_modified: "",
etag: "",
is_favorite: false,
}
}, [path, itemLabel, shareItemType])
useEffect(() => { useEffect(() => {
if (path) { if (path) {
setMode("public") setLinkAccessMode("public")
setPermissionMode("viewer") setPermissionMode("viewer")
setFolderPermissions(folderPermissionsFromRole("viewer")) setFolderPermissions(folderPermissionsFromRole("viewer"))
setContactEmail("") setContactEmail("")
@ -483,7 +466,7 @@ export function ShareDialog() {
useEffect(() => { useEffect(() => {
const email = contactEmail.trim().toLowerCase() const email = contactEmail.trim().toLowerCase()
if (mode !== "contact" || !email.includes("@")) { if (!email.includes("@")) {
setRecipientRegistered(null) setRecipientRegistered(null)
return return
} }
@ -493,12 +476,12 @@ export function ShareDialog() {
.catch(() => setRecipientRegistered(null)) .catch(() => setRecipientRegistered(null))
}, 350) }, 350)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [contactEmail, mode, lookupRecipientEmail]) }, [contactEmail, lookupRecipientEmail])
const advancedPermissionBits = folderPermissionsToBitmask(folderPermissions) const advancedPermissionBits = folderPermissionsToBitmask(folderPermissions)
const canCreateShare = const canCreateLink =
(mode !== "contact" || contactEmail.trim().includes("@")) && !isFolder || permissionMode !== "advanced" || advancedPermissionBits > 0
(!isFolder || permissionMode !== "advanced" || advancedPermissionBits > 0) const canShareWithContact = hasValidContactEmail && canCreateLink
const setFolderPermission = (id: FolderSharePermissionId, checked: boolean) => { const setFolderPermission = (id: FolderSharePermissionId, checked: boolean) => {
setFolderPermissions((prev) => ({ ...prev, [id]: checked })) setFolderPermissions((prev) => ({ ...prev, [id]: checked }))
@ -521,44 +504,49 @@ export function ShareDialog() {
return base return base
} }
const onShare = async () => { const onCreateLink = async () => {
if (!path || !canCreateShare) return if (!path || !canCreateLink) return
try { try {
const payload = sharePayload()
const share = await createShare.mutateAsync({ const share = await createShare.mutateAsync({
...payload, ...sharePayload(),
mode, mode: linkAccessMode,
...(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("")
} else {
const link = shareLinkForCopy(share) const link = shareLinkForCopy(share)
if (link) { if (link) {
await navigator.clipboard.writeText(link) await navigator.clipboard.writeText(link)
toast.success( toast.success(
mode === "internal" linkAccessMode === "internal"
? "Lien interne copié dans le presse-papiers" ? "Lien interne copié"
: "Lien public copié dans le presse-papiers" : "Lien public copié"
) )
} else { } else {
toast.success("Partage créé") 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 (share.access_mode === "user" || share.share_type === NC_SHARE_TYPE.USER) {
toast.success("Partagé — visible dans « Partagés avec moi »")
} else {
toast.success("Invitation envoyée par e-mail")
}
setContactEmail("")
setContactNote("")
setContactQuery("")
void refetchShares() void refetchShares()
} catch { } catch {
toast.error("Partage impossible") toast.error("Partage impossible")
@ -580,96 +568,27 @@ export function ShareDialog() {
const existingShares = data?.shares ?? [] const existingShares = data?.shares ?? []
const actionLabel =
mode === "contact"
? "Partager"
: mode === "internal"
? "Créer le lien interne"
: "Créer le lien public"
return ( return (
<TooltipProvider delayDuration={400}>
<Dialog open={Boolean(path)} onOpenChange={(open) => !open && close()}> <Dialog open={Boolean(path)} onOpenChange={(open) => !open && close()}>
<DialogContent <DialogContent
overlayClassName={DRIVE_DIALOG_OVERLAY} overlayClassName={DRIVE_DIALOG_OVERLAY}
className={cn(DRIVE_DIALOG_CONTENT, "sm:max-w-[520px]")} className={cn(DRIVE_DIALOG_CONTENT, "sm:max-w-[480px]")}
> >
<DialogHeader className={cn(DRIVE_DIALOG_HEADER, "space-y-4")}> <DialogHeader className={cn(DRIVE_DIALOG_HEADER, "pb-4")}>
<div className="flex items-start gap-3 pr-8">
{filePreview ? (
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#f1f3f4] dark:bg-[#35363a]">
<DriveFileTypeIcon file={filePreview} size="md" />
</div>
) : (
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#e8f0fe] text-[#1967d2] dark:bg-[#1a377a]/50 dark:text-[#8ab4f8]">
<Icon icon="mdi:link-variant" className="h-5 w-5" aria-hidden />
</div>
)}
<div className="min-w-0 flex-1">
<DialogTitle className={cn("text-base font-medium", DRIVE_TEXT_TITLE)}> <DialogTitle className={cn("text-base font-medium", DRIVE_TEXT_TITLE)}>
{isFolder ? "Partager le dossier" : "Partager le fichier"} Partager « {itemLabel} »
</DialogTitle> </DialogTitle>
<DialogDescription className={cn("mt-1 truncate text-sm", DRIVE_TEXT_SECONDARY)}> <DialogDescription className="sr-only">
{itemLabel} {isFolder ? "Partager le dossier" : "Partager le fichier"} {itemLabel}
</DialogDescription> </DialogDescription>
</div>
</div>
</DialogHeader> </DialogHeader>
<div className="max-h-[min(70vh,560px)] space-y-5 overflow-y-auto px-6 py-5"> <div className="max-h-[min(70vh,520px)] space-y-5 overflow-y-auto px-6 py-4">
<div className="grid grid-cols-3 gap-2"> {/* Ajouter des personnes */}
{MODE_OPTIONS.map((option) => { <div className="space-y-2">
const IconComponent = option.icon <div className="flex items-start gap-2">
const selected = mode === option.id <div className="relative min-w-0 flex-1">
return (
<button
key={option.id}
type="button"
onClick={() => setMode(option.id)}
className={cn(
"flex cursor-pointer flex-col items-start gap-1 rounded-xl border px-2.5 py-2.5 text-left transition-colors",
selected ? DRIVE_CARD_ACTIVE : DRIVE_CARD_IDLE
)}
>
<span className="flex items-center gap-1.5">
<IconComponent
className={cn(
"h-3.5 w-3.5",
selected ? "text-[#1967d2] dark:text-[#8ab4f8]" : DRIVE_TEXT_SECONDARY
)}
aria-hidden
/>
<span
className={cn(
"text-xs font-medium",
selected ? "text-[#1967d2] dark:text-[#8ab4f8]" : DRIVE_TEXT_PRIMARY
)}
>
{option.label}
</span>
</span>
</button>
)
})}
</div>
<div className="space-y-3">
<div>
<p className={cn("text-sm font-medium", DRIVE_TEXT_PRIMARY)}>{selectedMode.label}</p>
<p className={cn("mt-1 text-xs leading-relaxed", DRIVE_TEXT_SECONDARY)}>
{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."}
</p>
</div>
{mode === "contact" ? (
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="drive-share-contact-email" className={DRIVE_LABEL_CLASS}>
Adresse e-mail
</Label>
<Input <Input
id="drive-share-contact-email" id="drive-share-contact-email"
type="email" type="email"
@ -678,12 +597,12 @@ export function ShareDialog() {
setContactEmail(e.target.value) setContactEmail(e.target.value)
setContactQuery(e.target.value) setContactQuery(e.target.value)
}} }}
placeholder="nom@exemple.com" placeholder="Ajouter des personnes par e-mail"
autoComplete="off" autoComplete="off"
className={DRIVE_FIELD_CLASS} className={cn(DRIVE_FIELD_CLASS, "h-10 pr-3")}
/> />
{contactQuery.length >= 2 && contactResults.length > 0 ? ( {contactQuery.length >= 2 && contactResults.length > 0 ? (
<div className="max-h-32 overflow-y-auto rounded-lg border border-[#dadce0] bg-[#f8f9fa] dark:border-[#5f6368]/40 dark:bg-[#35363a]"> <div className="absolute top-full z-10 mt-1 w-full overflow-hidden rounded-lg border border-[#dadce0] bg-white shadow-md dark:border-[#5f6368]/40 dark:bg-[#292a2d]">
{contactResults.slice(0, 6).map((c) => {contactResults.slice(0, 6).map((c) =>
c.email ? ( c.email ? (
<button <button
@ -702,42 +621,46 @@ export function ShareDialog() {
)} )}
</div> </div>
) : null} ) : null}
{recipientRegistered === true ? ( </div>
<p className="flex items-center gap-1.5 text-xs text-[#188038] dark:text-[#81c995]"> {showContactExtras ? (
<UserRound className="h-3.5 w-3.5" aria-hidden /> <PermissionSelect
Compte inscrit partage direct dans « Partagés avec moi » value={permissionMode}
</p> isFolder={isFolder}
) : null} onChange={onPermissionModeChange}
{recipientRegistered === false ? ( className="min-w-[132px]"
<p className={cn("flex items-center gap-1.5 text-xs", DRIVE_TEXT_SECONDARY)}> />
<Mail className="h-3.5 w-3.5" aria-hidden />
Pas de compte invitation par e-mail avec lien public
</p>
) : null} ) : null}
</div> </div>
<div className="space-y-1.5">
<Label htmlFor="drive-share-contact-note" className={DRIVE_LABEL_CLASS}> {showContactExtras ? (
Message (optionnel) <div className="space-y-2 animate-in fade-in-0 slide-in-from-top-1 duration-150">
</Label>
<Textarea <Textarea
id="drive-share-contact-note" id="drive-share-contact-note"
value={contactNote} value={contactNote}
onChange={(e) => setContactNote(e.target.value)} onChange={(e) => setContactNote(e.target.value)}
placeholder="Ajouter un message pour le destinataire…" placeholder="Message (optionnel)"
rows={2} rows={2}
className={DRIVE_TEXTAREA_CLASS} className={DRIVE_TEXTAREA_CLASS}
/> />
</div> {recipientRegistered === true ? (
</div> <p className="flex items-center gap-1.5 text-xs text-[#188038] dark:text-[#81c995]">
<UserRound className="h-3 w-3" aria-hidden />
Compte inscrit partage direct
</p>
) : recipientRegistered === false ? (
<p className={cn("flex items-center gap-1.5 text-xs", DRIVE_TEXT_SECONDARY)}>
<Mail className="h-3 w-3" aria-hidden />
Invitation par e-mail avec lien public
</p>
) : null} ) : null}
{isFolder && permissionMode === "advanced" ? (
<SharePermissionsPanel <AdvancedPermissionsPanel
isFolder={isFolder}
permissionMode={permissionMode}
folderPermissions={folderPermissions} folderPermissions={folderPermissions}
onPermissionModeChange={onPermissionModeChange}
onFolderPermissionChange={setFolderPermission} onFolderPermissionChange={setFolderPermission}
/> />
) : null}
</div>
) : null}
</div> </div>
<ActiveSharesPanel <ActiveSharesPanel
@ -748,42 +671,110 @@ export function ShareDialog() {
deletingShareId={deletingShareId} deletingShareId={deletingShareId}
onDeleteShare={(shareId) => void onDeleteShare(shareId)} onDeleteShare={(shareId) => void onDeleteShare(shareId)}
/> />
{/* Accès général */}
<div className={cn("space-y-2", existingShares.length > 0 && "border-t pt-4")}>
<p className={cn("text-sm font-medium", DRIVE_TEXT_PRIMARY)}>Accès général</p>
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#e6f4ea] text-[#188038] dark:bg-[#1e3a2f]/50 dark:text-[#81c995]">
<LinkAccessIcon className="h-4 w-4" aria-hidden />
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<Select value={linkAccessMode} onValueChange={(v) => setLinkAccessMode(v as LinkAccessMode)}>
<SelectTrigger
variant="ghost"
size="sm"
className="h-auto w-full justify-start gap-1 p-0 text-sm font-medium"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{LINK_ACCESS_OPTIONS.map((option) => {
const Icon = option.icon
return (
<SelectItem key={option.id} value={option.id}>
<span className="flex items-center gap-2">
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
{option.label}
</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
<p className={cn("text-xs leading-relaxed", DRIVE_TEXT_SECONDARY)}>
{selectedLinkAccess.description}
</p>
</div>
{!showContactExtras ? (
<PermissionSelect
value={permissionMode}
isFolder={isFolder}
onChange={onPermissionModeChange}
className="min-w-[132px]"
/>
) : null}
</div> </div>
<DialogFooter className={DRIVE_DIALOG_FOOTER}> {!showContactExtras && isFolder && permissionMode === "advanced" ? (
<AdvancedPermissionsPanel
folderPermissions={folderPermissions}
onFolderPermissionChange={setFolderPermission}
/>
) : null}
</div>
</div>
<DialogFooter className={cn(DRIVE_DIALOG_FOOTER, "justify-between sm:justify-between")}>
<Button <Button
type="button" type="button"
variant="ghost" variant="outline"
className={DRIVE_BTN_GHOST} className={cn(
onClick={close} "rounded-full border-[#dadce0] bg-white text-sm font-medium text-[#1a73e8] hover:bg-[#f8f9fa] dark:border-[#5f6368]/40 dark:bg-transparent dark:text-[#8ab4f8] dark:hover:bg-[#3c4043]/50"
)}
disabled={createShare.isPending || !canCreateLink}
onClick={() => void onCreateLink()}
> >
Annuler {createShare.isPending && !showContactExtras ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Link2 className="h-4 w-4" />
)}
Copier le lien
</Button> </Button>
<div className="flex gap-2">
{showContactExtras ? (
<Button <Button
type="button" type="button"
className={DRIVE_BTN_PRIMARY} className={cn(DRIVE_BTN_PRIMARY, "rounded-full px-6")}
disabled={createShare.isPending || !canCreateShare} disabled={createShare.isPending || !canShareWithContact}
onClick={() => void onShare()} onClick={() => void onShareWithContact()}
> >
{createShare.isPending ? ( {createShare.isPending ? (
<> <>
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
{mode === "contact" ? "Envoi…" : "Création…"} Envoi
</>
) : mode === "contact" ? (
<>
<Mail className="h-4 w-4" />
{actionLabel}
</> </>
) : ( ) : (
<> <>
<Link2 className="h-4 w-4" /> <Mail className="h-4 w-4" />
{actionLabel} Partager
</> </>
)} )}
</Button> </Button>
) : null}
<Button
type="button"
className={cn(DRIVE_BTN_PRIMARY, "rounded-full px-6")}
onClick={close}
>
{showContactExtras ? "Annuler" : "Terminé"}
</Button>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</TooltipProvider>
) )
} }

View File

@ -27,17 +27,24 @@ function SelectValue({
function SelectTrigger({ function SelectTrigger({
className, className,
size = 'default', size = 'default',
variant = 'default',
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default' size?: 'sm' | 'default'
variant?: 'default' | 'ghost'
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
data-variant={variant}
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md bg-transparent text-sm whitespace-nowrap transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variant === 'default' &&
"border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 border px-3 py-2 shadow-xs focus-visible:ring-[3px]",
variant === 'ghost' &&
"border-0 shadow-none hover:bg-transparent focus-visible:ring-0 dark:bg-transparent dark:hover:bg-transparent dark:data-[state=open]:bg-transparent",
className, className,
)} )}
{...props} {...props}

View File

@ -38,15 +38,39 @@ export type DocPageBorders = {
offsetFrom?: "page" | "text" offsetFrom?: "page" | "text"
} }
export type DocPageHeaderFooter = {
content: Record<string, unknown>
heightMm?: number
}
export type DocPageNumberSettings = {
enabled: boolean
placement: "header" | "footer"
align: "left" | "center" | "right"
startAt: number
showOnFirstPage: boolean
}
export type DocPageSetup = { export type DocPageSetup = {
widthMm: number widthMm: number
heightMm: number heightMm: number
marginsMm: DocPageMarginsMm marginsMm: DocPageMarginsMm
/** Distance from page top to header content (cm/mm from top). */
headerMarginMm?: number
/** Distance from page bottom to footer content. */
footerMarginMm?: number
formatId?: PageFormatId | null formatId?: PageFormatId | null
orientation?: "portrait" | "landscape" orientation?: "portrait" | "landscape"
pageColor?: string | null pageColor?: string | null
pageBackground?: DocPageBackground | null pageBackground?: DocPageBackground | null
borders?: DocPageBorders | null borders?: DocPageBorders | null
header?: DocPageHeaderFooter | null
footer?: DocPageHeaderFooter | null
headerFirstPage?: DocPageHeaderFooter | null
footerFirstPage?: DocPageHeaderFooter | null
headerFooterDifferentFirstPage?: boolean
headerFooterDifferentOddEven?: boolean
pageNumbers?: DocPageNumberSettings | null
} }
export type DocPageBorderCss = { export type DocPageBorderCss = {
@ -65,6 +89,15 @@ export type DocPageLayout = {
pageBackgroundLayers?: DocPageBackgroundLayers pageBackgroundLayers?: DocPageBackgroundLayers
sheetBorderCss?: DocPageBorderCss sheetBorderCss?: DocPageBorderCss
textAreaBorderCss?: DocPageBorderCss textAreaBorderCss?: DocPageBorderCss
header?: DocPageHeaderFooter | null
footer?: DocPageHeaderFooter | null
headerFirstPage?: DocPageHeaderFooter | null
footerFirstPage?: DocPageHeaderFooter | null
headerMarginMm?: number
footerMarginMm?: number
headerFooterDifferentFirstPage?: boolean
headerFooterDifferentOddEven?: boolean
pageNumbers?: DocPageNumberSettings | null
} }
export function twipsToMm(twips: number): number { export function twipsToMm(twips: number): number {
@ -75,6 +108,10 @@ export function mmToPx(mm: number): number {
return Math.round(mm * MM_TO_PX) return Math.round(mm * MM_TO_PX)
} }
export function pxToMm(px: number): number {
return Math.round((px / MM_TO_PX) * 100) / 100
}
export function defaultPageMarginsMm(): DocPageMarginsMm { export function defaultPageMarginsMm(): DocPageMarginsMm {
const cm = 2 const cm = 2
return { top: cm * 10, right: cm * 10, bottom: cm * 10, left: cm * 10 } return { top: cm * 10, right: cm * 10, bottom: cm * 10, left: cm * 10 }
@ -152,6 +189,15 @@ export function resolveDocumentPageLayout(
}, },
pageColor: normalizePageColor(pageSetup.pageColor), pageColor: normalizePageColor(pageSetup.pageColor),
pageBackgroundLayers: resolvePageBackgroundLayers(pageSetup.pageBackground), pageBackgroundLayers: resolvePageBackgroundLayers(pageSetup.pageBackground),
header: pageSetup.header ?? null,
footer: pageSetup.footer ?? null,
headerFirstPage: pageSetup.headerFirstPage ?? null,
footerFirstPage: pageSetup.footerFirstPage ?? null,
headerMarginMm: pageSetup.headerMarginMm,
footerMarginMm: pageSetup.footerMarginMm,
headerFooterDifferentFirstPage: pageSetup.headerFooterDifferentFirstPage ?? false,
headerFooterDifferentOddEven: pageSetup.headerFooterDifferentOddEven ?? false,
pageNumbers: pageSetup.pageNumbers ?? null,
...borderLayers, ...borderLayers,
} }
} }
@ -308,6 +354,13 @@ export function buildPageSetupFromDraft(
pageColor: pageColor === "#ffffff" ? null : pageColor, pageColor: pageColor === "#ffffff" ? null : pageColor,
pageBackground: previous?.pageBackground ?? null, pageBackground: previous?.pageBackground ?? null,
borders: previous?.borders ?? null, borders: previous?.borders ?? null,
header: previous?.header ?? null,
footer: previous?.footer ?? null,
headerMarginMm: previous?.headerMarginMm,
footerMarginMm: previous?.footerMarginMm,
headerFooterDifferentFirstPage: previous?.headerFooterDifferentFirstPage ?? false,
headerFooterDifferentOddEven: previous?.headerFooterDifferentOddEven ?? false,
pageNumbers: previous?.pageNumbers ?? null,
} }
} }
@ -345,6 +398,8 @@ function parseSectPr(sectionXml: string): Omit<DocPageSetup, "pageColor" | "page
const right = readTwipsAttr(pgMarMatch[0], "right") const right = readTwipsAttr(pgMarMatch[0], "right")
const bottom = readTwipsAttr(pgMarMatch[0], "bottom") const bottom = readTwipsAttr(pgMarMatch[0], "bottom")
const left = readTwipsAttr(pgMarMatch[0], "left") const left = readTwipsAttr(pgMarMatch[0], "left")
const header = readTwipsAttr(pgMarMatch[0], "header")
const footer = readTwipsAttr(pgMarMatch[0], "footer")
if (top == null || right == null || bottom == null || left == null) return null if (top == null || right == null || bottom == null || left == null) return null
const marginsMm = { const marginsMm = {
@ -358,6 +413,8 @@ function parseSectPr(sectionXml: string): Omit<DocPageSetup, "pageColor" | "page
widthMm, widthMm,
heightMm, heightMm,
marginsMm, marginsMm,
headerMarginMm: header != null ? twipsToMm(header) : marginsMm.top,
footerMarginMm: footer != null ? twipsToMm(footer) : marginsMm.bottom,
formatId: matchPageFormatId(widthMm, heightMm), formatId: matchPageFormatId(widthMm, heightMm),
orientation: orient === "landscape" ? "landscape" : "portrait", orientation: orient === "landscape" ? "landscape" : "portrait",
borders: parsePgBorders(sectionXml), borders: parsePgBorders(sectionXml),

View File

@ -0,0 +1,89 @@
import type { TipTapJSON } from "@/lib/drive/richtext-import"
const BASE64_PREFIX = "data:"
/** Collect base64 image src values from TipTap JSON. */
export function collectBase64ImageSrcs(content: TipTapJSON): string[] {
const srcs: string[] = []
const walk = (node: unknown) => {
if (!node || typeof node !== "object") return
const record = node as TipTapJSON
const attrs = record.attrs as Record<string, unknown> | undefined
if (
(record.type === "image" ||
record.type === "docsGraphic" ||
record.type === "docsInlineGraphic") &&
typeof attrs?.src === "string" &&
attrs.src.startsWith(BASE64_PREFIX)
) {
srcs.push(attrs.src)
}
if (Array.isArray(record.content)) record.content.forEach(walk)
}
walk(content)
return srcs
}
export type UploadedAsset = {
assetId: string
url: string
}
/** Upload a base64 image and return asset reference. Phase 2 blob migration. */
export async function uploadGraphicAsset(
src: string,
uploadFn: (body: { dataUrl: string }) => Promise<UploadedAsset>
): Promise<UploadedAsset | null> {
if (!src.startsWith(BASE64_PREFIX)) return null
try {
return await uploadFn({ dataUrl: src })
} catch {
return null
}
}
/** Replace base64 src with asset URL in TipTap JSON (lazy migration). */
export function applyAssetToContent(
content: TipTapJSON,
src: string,
asset: UploadedAsset
): TipTapJSON {
const walk = (node: unknown): unknown => {
if (!node || typeof node !== "object") return node
if (Array.isArray(node)) return node.map(walk)
const record = node as TipTapJSON
const attrs = record.attrs as Record<string, unknown> | undefined
if (
attrs &&
attrs.src === src &&
(record.type === "image" ||
record.type === "docsGraphic" ||
record.type === "docsInlineGraphic")
) {
return {
...record,
attrs: { ...attrs, src: asset.url, assetId: asset.assetId },
}
}
if (Array.isArray(record.content)) {
return { ...record, content: record.content.map(walk) }
}
return record
}
const next = walk(content)
return (next && typeof next === "object" ? next : content) as TipTapJSON
}
/** Migrate all base64 images in document to backend assets when uploadFn provided. */
export async function migrateBase64ImagesInContent(
content: TipTapJSON,
uploadFn: (body: { dataUrl: string }) => Promise<UploadedAsset>
): Promise<TipTapJSON> {
let result = content
const srcs = [...new Set(collectBase64ImageSrcs(content))]
for (const src of srcs) {
const asset = await uploadGraphicAsset(src, uploadFn)
if (asset) result = applyAssetToContent(result, src, asset)
}
return result
}

View File

@ -0,0 +1,206 @@
import type { TipTapJSON } from "@/lib/drive/richtext-import"
import {
DOCS_GRAPHIC_DEFAULTS,
imageAttrsToGraphic,
parseGraphicAttrs,
type DocsGraphicWrap,
type DocsGraphicPlacement,
} from "./docs-graphic-types.ts"
const LEGACY_IMAGE_KEYS = [
"src",
"alt",
"title",
"width",
"height",
"placement",
"wrap",
"floatSide",
"x",
"y",
"rotationDeg",
"zIndex",
"cropX",
"cropY",
"cropWidth",
"cropHeight",
"cropShape",
"assetId",
"opacity",
"shadow",
] as const
const DOCX_WRAP_MAP: Record<string, DocsGraphicWrap> = {
inline: "inline",
square: "square",
tight: "tight",
through: "through",
topAndBottom: "top-bottom",
topbottom: "top-bottom",
behind: "behind",
infront: "in-front",
inFront: "in-front",
}
const DOCX_PLACEMENT_MAP: Record<string, DocsGraphicPlacement> = {
inline: "inline",
block: "block",
absolute: "absolute",
anchored: "absolute",
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
}
function paragraphIsSingleImage(node: TipTapJSON): boolean {
if (node.type !== "paragraph" || !Array.isArray(node.content) || node.content.length !== 1) {
return false
}
const child = node.content[0] as TipTapJSON
return child.type === "image" || child.type === "docsInlineGraphic"
}
function upgradeImageNode(raw: TipTapJSON, asBlock: boolean): TipTapJSON {
const attrs = isRecord(raw.attrs) ? raw.attrs : {}
const graphic = imageAttrsToGraphic(attrs)
if (asBlock) {
return {
type: "docsGraphic",
attrs: {
...graphic,
placement: graphic.placement === "inline" ? "block" : graphic.placement,
wrap: graphic.wrap === "inline" ? "square" : graphic.wrap,
},
}
}
return {
type: "docsInlineGraphic",
attrs: {
...graphic,
placement: "inline",
wrap: graphic.wrap === "square" ? "inline" : graphic.wrap,
},
}
}
function upgradeGraphicNode(raw: TipTapJSON): TipTapJSON {
const attrs = isRecord(raw.attrs) ? parseGraphicAttrs(raw.attrs) : DOCS_GRAPHIC_DEFAULTS
if (raw.type === "docsGraphic") {
return { type: "docsGraphic", attrs }
}
if (raw.type === "docsInlineGraphic") {
return { type: "docsInlineGraphic", attrs }
}
if (raw.type === "image") {
return upgradeImageNode(raw, false)
}
return raw
}
function mapImportedGraphicAttrs(attrs: Record<string, unknown>): Record<string, unknown> {
const wrapRaw = attrs.wrap ?? attrs.textWrap ?? attrs.layout
const placementRaw = attrs.placement ?? attrs.position ?? attrs.layoutMode
const wrap =
typeof wrapRaw === "string" ? (DOCX_WRAP_MAP[wrapRaw] ?? wrapRaw) : undefined
const placement =
typeof placementRaw === "string"
? (DOCX_PLACEMENT_MAP[placementRaw] ?? placementRaw)
: undefined
return {
...attrs,
...(wrap ? { wrap } : {}),
...(placement ? { placement } : {}),
floatSide: attrs.floatSide ?? attrs.align ?? attrs.horizontalAlign,
}
}
/** Upgrade legacy `image` nodes and normalize graphic attrs after DOCX import. */
export function normalizeImportedGraphics(content: TipTapJSON): TipTapJSON {
const walk = (node: unknown): unknown => {
if (!node || typeof node !== "object") return node
if (Array.isArray(node)) return node.map(walk)
const record = node as TipTapJSON
if (record.type === "image" && isRecord(record.attrs)) {
const mapped = mapImportedGraphicAttrs(record.attrs)
const placement = mapped.placement as string | undefined
const wrap = mapped.wrap as string | undefined
const asBlock =
wrap === "square" ||
wrap === "tight" ||
wrap === "through" ||
wrap === "top-bottom" ||
wrap === "behind" ||
wrap === "in-front" ||
placement === "block" ||
placement === "absolute"
return upgradeImageNode({ ...record, attrs: mapped }, asBlock)
}
if (
(record.type === "docsGraphic" || record.type === "docsInlineGraphic") &&
isRecord(record.attrs)
) {
record.attrs = parseGraphicAttrs(mapImportedGraphicAttrs(record.attrs))
}
if (record.type === "paragraph" && paragraphIsSingleImage(record)) {
const child = record.content![0] as TipTapJSON
if (child.type === "image") {
const upgraded = upgradeImageNode(
{ ...child, attrs: mapImportedGraphicAttrs((child.attrs as Record<string, unknown>) ?? {}) },
true
)
return upgraded
}
}
if (Array.isArray(record.content)) {
const nextContent = record.content.map(walk).filter(Boolean)
if (record.type === "paragraph") {
return { ...record, content: nextContent }
}
return { ...record, content: nextContent }
}
if (record.type === "image" || record.type === "docsGraphic" || record.type === "docsInlineGraphic") {
return upgradeGraphicNode(record)
}
return record
}
const normalized = walk(content)
if (!normalized || typeof normalized !== "object") {
return { type: "doc", content: [{ type: "paragraph" }] }
}
return normalized as TipTapJSON
}
/** Keep legacy inline images compatible with @tiptap/extension-image when needed. */
export function normalizeLegacyImageAttrs(content: TipTapJSON): TipTapJSON {
const walk = (node: unknown): unknown => {
if (!node || typeof node !== "object") return node
if (Array.isArray(node)) return node.map(walk)
const record = node as TipTapJSON
if (record.type === "image" && isRecord(record.attrs)) {
const attrs: Record<string, unknown> = {}
for (const key of LEGACY_IMAGE_KEYS) {
const value = record.attrs[key]
if (value != null && value !== "") attrs[key] = value
}
if (typeof attrs.src !== "string" || !attrs.src) return null
return { ...record, attrs }
}
if (Array.isArray(record.content)) {
return { ...record, content: record.content.map(walk).filter(Boolean) }
}
return record
}
const normalized = walk(content)
if (!normalized || typeof normalized !== "object") {
return { type: "doc", content: [{ type: "paragraph" }] }
}
return normalized as TipTapJSON
}

View File

@ -0,0 +1,151 @@
import type { CSSProperties } from "react"
import {
type DocsGraphicAttrs,
parseGraphicAttrs,
} from "./docs-graphic-types.ts"
export type DocsGraphicLayoutStyle = {
wrapper: CSSProperties
inner: CSSProperties
content: CSSProperties
behindText: boolean
inFrontText: boolean
}
export function computeGraphicLayoutStyle(
raw: Record<string, unknown> | DocsGraphicAttrs
): DocsGraphicLayoutStyle {
const attrs = "graphicType" in raw && typeof raw.graphicType === "string"
? (raw as DocsGraphicAttrs)
: parseGraphicAttrs(raw)
const width = attrs.width
const height = attrs.height
const rotation = attrs.rotationDeg
const transformParts = rotation ? [`rotate(${rotation}deg)`] : []
const behindText = attrs.wrap === "behind"
const inFrontText = attrs.wrap === "in-front"
const isAbsolute = attrs.placement === "absolute" || behindText || inFrontText
const wrapper: CSSProperties = {
width: isAbsolute ? undefined : width,
height: isAbsolute ? undefined : height,
maxWidth: "100%",
}
const inner: CSSProperties = {
width,
height,
transform: transformParts.length ? transformParts.join(" ") : undefined,
transformOrigin: "center center",
position: isAbsolute ? "absolute" : "relative",
left: isAbsolute ? attrs.x : undefined,
top: isAbsolute ? attrs.y : undefined,
zIndex: behindText ? 0 : inFrontText ? 20 : attrs.zIndex || undefined,
pointerEvents: behindText ? "none" : "auto",
opacity: attrs.opacity < 1 ? attrs.opacity : undefined,
boxShadow: attrs.shadow || undefined,
}
if (attrs.placement === "inline" || attrs.wrap === "inline") {
inner.display = "inline-block"
inner.verticalAlign = "baseline"
} else if (attrs.wrap === "top-bottom") {
inner.display = "block"
inner.clear = "both"
inner.marginBlock = "8px"
if (attrs.floatSide === "center") {
inner.marginInline = "auto"
}
} else if (attrs.wrap === "square" || attrs.wrap === "tight" || attrs.wrap === "through") {
inner.display = "block"
inner.float = attrs.floatSide === "right" ? "right" : "left"
inner.marginInlineStart = attrs.floatSide === "right" ? "12px" : undefined
inner.marginInlineEnd = attrs.floatSide === "right" ? undefined : "12px"
inner.marginBlock = "4px"
if (attrs.wrap === "tight") {
inner.shapeOutside = "margin-box"
inner.marginInlineStart = attrs.floatSide === "right" ? "6px" : undefined
inner.marginInlineEnd = attrs.floatSide === "right" ? undefined : "6px"
}
if (attrs.wrap === "through") {
inner.opacity = 0.85
inner.mixBlendMode = "multiply"
}
} else if (attrs.placement === "block") {
inner.display = "block"
inner.marginBlock = "8px"
if (attrs.floatSide === "center") {
inner.marginInline = "auto"
}
}
const content: CSSProperties = {
width: "100%",
height: "100%",
overflow: "hidden",
}
return { wrapper, inner, content, behindText, inFrontText }
}
export const RESIZE_HANDLES = [
"nw",
"n",
"ne",
"e",
"se",
"s",
"sw",
"w",
] as const
export type ResizeHandle = (typeof RESIZE_HANDLES)[number]
export function resizeWithHandle(
handle: ResizeHandle,
startWidth: number,
startHeight: number,
dx: number,
dy: number,
minSize = 24,
lockAspect = false
): { width: number; height: number; xOffset: number; yOffset: number } {
let width = startWidth
let height = startHeight
let xOffset = 0
let yOffset = 0
if (lockAspect) {
const aspect = startWidth / Math.max(startHeight, 1)
if (Math.abs(dx) >= Math.abs(dy)) {
width = startWidth + (handle.includes("w") ? -dx : handle.includes("e") ? dx : dx)
height = width / aspect
} else {
height = startHeight + (handle.includes("n") ? -dy : handle.includes("s") ? dy : dy)
width = height * aspect
}
if (handle.includes("w")) xOffset = startWidth - width
if (handle.includes("n")) yOffset = startHeight - height
} else {
if (handle.includes("e")) width = startWidth + dx
if (handle.includes("w")) {
width = startWidth - dx
xOffset = dx
}
if (handle.includes("s")) height = startHeight + dy
if (handle.includes("n")) {
height = startHeight - dy
yOffset = dy
}
}
width = Math.max(minSize, width)
height = Math.max(minSize, height)
if (handle.includes("w") && width === minSize) xOffset = startWidth - minSize
if (handle.includes("n") && height === minSize) yOffset = startHeight - minSize
return { width: Math.round(width), height: Math.round(height), xOffset, yOffset }
}

View File

@ -0,0 +1,256 @@
export type DocsGraphicType = "image" | "shape" | "gradient"
export type DocsGraphicPlacement = "inline" | "block" | "absolute"
/** Text wrap / layering relative to body text */
export type DocsGraphicWrap =
| "inline"
| "square"
| "tight"
| "through"
| "top-bottom"
| "behind"
| "in-front"
export type DocsGraphicFloatSide = "left" | "right" | "center"
export type DocsShapeType = "rect" | "ellipse" | "line" | "arrow"
export type DocsCropShape = "rect" | "ellipse"
export type DocsGraphicAttrs = {
graphicType: DocsGraphicType
src: string | null
alt: string
assetId: string | null
shapeType: DocsShapeType
fill: string
stroke: string
strokeWidth: number
gradientCss: string
gradientAngle: number
gradientColor1: string
gradientColor2: string
width: number
height: number
placement: DocsGraphicPlacement
wrap: DocsGraphicWrap
floatSide: DocsGraphicFloatSide
x: number
y: number
rotationDeg: number
zIndex: number
/** Normalized crop region 01 relative to source image */
cropX: number
cropY: number
cropWidth: number
cropHeight: number
cropShape: DocsCropShape
opacity: number
shadow: string
}
export const DOCS_GRAPHIC_DEFAULTS: DocsGraphicAttrs = {
graphicType: "image",
src: null,
alt: "",
assetId: null,
shapeType: "rect",
fill: "#4285f4",
stroke: "#1a73e8",
strokeWidth: 2,
gradientCss: "",
gradientAngle: 180,
gradientColor1: "#4285f4",
gradientColor2: "#34a853",
width: 240,
height: 160,
placement: "block",
wrap: "square",
floatSide: "left",
x: 0,
y: 0,
rotationDeg: 0,
zIndex: 0,
cropX: 0,
cropY: 0,
cropWidth: 1,
cropHeight: 1,
cropShape: "rect",
opacity: 1,
shadow: "",
}
export const DOCS_GRAPHIC_WRAP_LABELS: Record<DocsGraphicWrap, string> = {
inline: "En ligne avec le texte",
square: "Carré",
tight: "Rapproché",
through: "À travers",
"top-bottom": "Haut et bas",
behind: "Derrière le texte",
"in-front": "Devant le texte",
}
export const DOCS_GRAPHIC_PLACEMENT_LABELS: Record<DocsGraphicPlacement, string> = {
inline: "En ligne",
block: "Bloc",
absolute: "Position absolue",
}
export function buildGradientCss(
angle: number,
color1: string,
color2: string
): string {
return `linear-gradient(${angle}deg, ${color1}, ${color2})`
}
export function parseGraphicAttrs(raw: Record<string, unknown>): DocsGraphicAttrs {
const num = (key: keyof DocsGraphicAttrs, fallback: number) => {
const value = raw[key]
return typeof value === "number" && Number.isFinite(value) ? value : fallback
}
const str = (key: keyof DocsGraphicAttrs, fallback: string) => {
const value = raw[key]
return typeof value === "string" ? value : fallback
}
const gradientAngle = num("gradientAngle", DOCS_GRAPHIC_DEFAULTS.gradientAngle)
const gradientColor1 = str("gradientColor1", DOCS_GRAPHIC_DEFAULTS.gradientColor1)
const gradientColor2 = str("gradientColor2", DOCS_GRAPHIC_DEFAULTS.gradientColor2)
const gradientCss =
str("gradientCss", "") ||
(raw.graphicType === "gradient"
? buildGradientCss(gradientAngle, gradientColor1, gradientColor2)
: "")
return {
graphicType:
raw.graphicType === "shape" || raw.graphicType === "gradient" || raw.graphicType === "image"
? raw.graphicType
: DOCS_GRAPHIC_DEFAULTS.graphicType,
src: typeof raw.src === "string" ? raw.src : null,
alt: str("alt", ""),
shapeType:
raw.shapeType === "ellipse" ||
raw.shapeType === "line" ||
raw.shapeType === "arrow" ||
raw.shapeType === "rect"
? raw.shapeType
: DOCS_GRAPHIC_DEFAULTS.shapeType,
fill: str("fill", DOCS_GRAPHIC_DEFAULTS.fill),
stroke: str("stroke", DOCS_GRAPHIC_DEFAULTS.stroke),
strokeWidth: num("strokeWidth", DOCS_GRAPHIC_DEFAULTS.strokeWidth),
gradientCss,
gradientAngle,
gradientColor1,
gradientColor2,
width: Math.max(24, num("width", DOCS_GRAPHIC_DEFAULTS.width)),
height: Math.max(24, num("height", DOCS_GRAPHIC_DEFAULTS.height)),
placement:
raw.placement === "inline" || raw.placement === "block" || raw.placement === "absolute"
? raw.placement
: DOCS_GRAPHIC_DEFAULTS.placement,
wrap:
raw.wrap === "inline" ||
raw.wrap === "square" ||
raw.wrap === "tight" ||
raw.wrap === "through" ||
raw.wrap === "top-bottom" ||
raw.wrap === "behind" ||
raw.wrap === "in-front"
? raw.wrap
: DOCS_GRAPHIC_DEFAULTS.wrap,
floatSide:
raw.floatSide === "left" || raw.floatSide === "right" || raw.floatSide === "center"
? raw.floatSide
: DOCS_GRAPHIC_DEFAULTS.floatSide,
assetId: typeof raw.assetId === "string" && raw.assetId ? raw.assetId : null,
x: num("x", 0),
y: num("y", 0),
rotationDeg: num("rotationDeg", 0),
zIndex: num("zIndex", 0),
cropX: clamp01(num("cropX", 0)),
cropY: clamp01(num("cropY", 0)),
cropWidth: clamp01(num("cropWidth", 1), 1),
cropHeight: clamp01(num("cropHeight", 1), 1),
cropShape: raw.cropShape === "ellipse" ? "ellipse" : "rect",
opacity: clamp01(num("opacity", 1), 1),
shadow: str("shadow", ""),
}
}
function clamp01(value: number, fallback = 0): number {
if (!Number.isFinite(value)) return fallback
return Math.min(1, Math.max(0, value))
}
/** CSS styles to render a cropped image inside its frame. */
export function computeCropImageStyle(attrs: Pick<
DocsGraphicAttrs,
"cropX" | "cropY" | "cropWidth" | "cropHeight" | "cropShape"
>): { img: Record<string, string | number>; clipPath?: string } {
const hasCrop =
attrs.cropX > 0 ||
attrs.cropY > 0 ||
attrs.cropWidth < 1 ||
attrs.cropHeight < 1
if (!hasCrop) return { img: {} }
const scaleX = 1 / Math.max(attrs.cropWidth, 0.01)
const scaleY = 1 / Math.max(attrs.cropHeight, 0.01)
const offsetX = -(attrs.cropX * scaleX * 100)
const offsetY = -(attrs.cropY * scaleY * 100)
const img: Record<string, string | number> = {
width: `${scaleX * 100}%`,
height: `${scaleY * 100}%`,
maxWidth: "none",
objectFit: "cover",
objectPosition: `${offsetX}% ${offsetY}%`,
}
const clipPath =
attrs.cropShape === "ellipse" ? "ellipse(50% 50% at 50% 50%)" : undefined
return { img, clipPath }
}
export function imageAttrsToGraphic(raw: Record<string, unknown>): Partial<DocsGraphicAttrs> {
const width =
typeof raw.width === "number"
? raw.width
: typeof raw.width === "string"
? Number(raw.width) || DOCS_GRAPHIC_DEFAULTS.width
: DOCS_GRAPHIC_DEFAULTS.width
const height =
typeof raw.height === "number"
? raw.height
: typeof raw.height === "string"
? Number(raw.height) || DOCS_GRAPHIC_DEFAULTS.height
: DOCS_GRAPHIC_DEFAULTS.height
return parseGraphicAttrs({
graphicType: "image",
src: raw.src,
alt: raw.alt ?? "",
width,
height,
placement: raw.placement ?? "inline",
wrap: raw.wrap ?? "inline",
floatSide: raw.floatSide ?? "left",
x: raw.x ?? 0,
y: raw.y ?? 0,
rotationDeg: raw.rotationDeg ?? 0,
zIndex: raw.zIndex ?? 0,
cropX: raw.cropX ?? 0,
cropY: raw.cropY ?? 0,
cropWidth: raw.cropWidth ?? 1,
cropHeight: raw.cropHeight ?? 1,
cropShape: raw.cropShape ?? "rect",
assetId: raw.assetId ?? null,
opacity: raw.opacity ?? 1,
shadow: raw.shadow ?? "",
})
}

View File

@ -0,0 +1,76 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { computeGraphicLayoutStyle, resizeWithHandle } from "./docs-graphic-layout.ts"
import { normalizeImportedGraphics } from "./docs-graphic-import.ts"
import { DOCS_GRAPHIC_DEFAULTS } from "./docs-graphic-types.ts"
describe("docs-graphic", () => {
it("computeGraphicLayoutStyle applies float wrap", () => {
const layout = computeGraphicLayoutStyle({
...DOCS_GRAPHIC_DEFAULTS,
graphicType: "image",
wrap: "square",
floatSide: "left",
})
assert.equal(layout.inner.float, "left")
})
it("computeGraphicLayoutStyle applies absolute placement", () => {
const layout = computeGraphicLayoutStyle({
...DOCS_GRAPHIC_DEFAULTS,
graphicType: "shape",
placement: "absolute",
x: 40,
y: 20,
})
assert.equal(layout.inner.position, "absolute")
assert.equal(layout.inner.left, 40)
assert.equal(layout.inner.top, 20)
})
it("resizeWithHandle respects minimum size", () => {
const next = resizeWithHandle("se", 120, 80, -200, -200)
assert.equal(next.width, 24)
assert.equal(next.height, 24)
})
it("resizeWithHandle respects aspect lock", () => {
const next = resizeWithHandle("se", 200, 100, 100, 50, 24, true)
assert.equal(next.width, 300)
assert.equal(next.height, 150)
})
it("normalizeImportedGraphics upgrades standalone image paragraph", () => {
const result = normalizeImportedGraphics({
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "image", attrs: { src: "data:image/png;base64,abc", width: 200 } }],
},
],
})
assert.equal(result.content?.[0]?.type, "docsGraphic")
assert.equal((result.content?.[0]?.attrs as { graphicType?: string }).graphicType, "image")
})
it("normalizeImportedGraphics maps docx wrap aliases", () => {
const result = normalizeImportedGraphics({
type: "doc",
content: [
{
type: "docsGraphic",
attrs: {
graphicType: "image",
src: "https://example.com/a.png",
wrap: "topAndBottom",
placement: "anchored",
},
},
],
})
const attrs = result.content?.[0]?.attrs as { wrap?: string; placement?: string }
assert.equal(attrs.wrap, "top-bottom")
assert.equal(attrs.placement, "absolute")
})
})

View File

@ -0,0 +1,203 @@
import type { DocPageHeaderFooter, DocPageLayout, DocPageSetup } from "./doc-page-setup.ts"
import { mmToPx, pxToMm } from "./doc-page-setup.ts"
export type DocsHeaderFooterRegion = "header" | "footer"
/** Height of the Google Docsstyle header/footer chrome bar (px). */
export const DOCS_HF_CHROME_BAR_PX = 28
export function headerOffsetPx(pageLayout: DocPageLayout): number {
return pageLayout.headerMarginMm != null
? mmToPx(pageLayout.headerMarginMm)
: 0
}
export function footerOffsetPx(pageLayout: DocPageLayout): number {
return pageLayout.footerMarginMm != null
? mmToPx(pageLayout.footerMarginMm)
: 0
}
export function defaultHeaderZoneHeightPx(pageLayout: DocPageLayout): number {
return Math.max(24, pageLayout.marginsPx.top - headerOffsetPx(pageLayout))
}
export function defaultFooterZoneHeightPx(pageLayout: DocPageLayout): number {
return Math.max(24, pageLayout.marginsPx.bottom - footerOffsetPx(pageLayout))
}
export function regionZoneHeightPx(
region: DocPageHeaderFooter | null | undefined,
defaultPx: number
): number {
if (region?.heightMm != null && region.heightMm > 0) {
return Math.max(defaultPx, mmToPx(region.heightMm))
}
return defaultPx
}
/** Header/footer content shown on a given page index. */
export function resolveRegionForPage(
pageLayout: DocPageLayout,
region: DocsHeaderFooterRegion,
pageIndex: number
): DocPageHeaderFooter | null | undefined {
const differentFirst = pageLayout.headerFooterDifferentFirstPage ?? false
if (region === "header") {
if (pageIndex === 0 && differentFirst) return pageLayout.headerFirstPage ?? null
return pageLayout.header
}
if (pageIndex === 0 && differentFirst) return pageLayout.footerFirstPage ?? null
return pageLayout.footer
}
export function effectiveTopMarginPx(pageLayout: DocPageLayout): number {
const offset = headerOffsetPx(pageLayout)
const defaultZone = defaultHeaderZoneHeightPx(pageLayout)
const sharedH = regionZoneHeightPx(pageLayout.header, defaultZone)
const firstH = pageLayout.headerFooterDifferentFirstPage
? regionZoneHeightPx(pageLayout.headerFirstPage, defaultZone)
: sharedH
return Math.max(pageLayout.marginsPx.top, offset + Math.max(sharedH, firstH))
}
export function effectiveBottomMarginPx(pageLayout: DocPageLayout): number {
const offset = footerOffsetPx(pageLayout)
const defaultZone = defaultFooterZoneHeightPx(pageLayout)
const sharedH = regionZoneHeightPx(pageLayout.footer, defaultZone)
const firstH = pageLayout.headerFooterDifferentFirstPage
? regionZoneHeightPx(pageLayout.footerFirstPage, defaultZone)
: sharedH
return Math.max(pageLayout.marginsPx.bottom, offset + Math.max(sharedH, firstH))
}
export function effectiveMarginsPx(
pageLayout: DocPageLayout,
livePreview?: { region: DocsHeaderFooterRegion; heightPx: number } | null,
measuredHeights?: Partial<Record<DocsHeaderFooterRegion, number>>
) {
let top = effectiveTopMarginPx(pageLayout)
let bottom = effectiveBottomMarginPx(pageLayout)
if (measuredHeights?.header != null) {
const offset = headerOffsetPx(pageLayout)
top = Math.max(pageLayout.marginsPx.top, offset + measuredHeights.header)
}
if (measuredHeights?.footer != null) {
const offset = footerOffsetPx(pageLayout)
bottom = Math.max(pageLayout.marginsPx.bottom, offset + measuredHeights.footer)
}
if (livePreview) {
const { region, heightPx } = livePreview
if (region === "header") {
const offset = headerOffsetPx(pageLayout)
top = Math.max(pageLayout.marginsPx.top, offset + heightPx)
} else {
const offset = footerOffsetPx(pageLayout)
bottom = Math.max(pageLayout.marginsPx.bottom, offset + heightPx)
}
}
return {
top,
right: pageLayout.marginsPx.right,
bottom,
left: pageLayout.marginsPx.left,
}
}
export function pageRegionZoneHeightPx(
pageLayout: DocPageLayout,
region: DocsHeaderFooterRegion,
pageIndex: number
): number {
const data = resolveRegionForPage(pageLayout, region, pageIndex)
const defaultPx =
region === "header"
? defaultHeaderZoneHeightPx(pageLayout)
: defaultFooterZoneHeightPx(pageLayout)
return regionZoneHeightPx(data, defaultPx)
}
export function pageHeaderGeometry(
pageLayout: DocPageLayout,
pageTop: number,
pageIndex: number
) {
const offset = headerOffsetPx(pageLayout)
const zoneHeight = pageRegionZoneHeightPx(pageLayout, "header", pageIndex)
const zoneTop = pageTop + offset
return {
zoneTop,
zoneHeight,
bottomLine: zoneTop + zoneHeight,
offset,
}
}
export function pageFooterGeometry(
pageLayout: DocPageLayout,
pageTop: number,
pageHeight: number,
pageIndex: number
) {
const offset = footerOffsetPx(pageLayout)
const zoneHeight = pageRegionZoneHeightPx(pageLayout, "footer", pageIndex)
const zoneBottom = pageTop + pageHeight - offset
return {
zoneTop: zoneBottom - zoneHeight,
zoneHeight,
zoneBottom,
topLine: zoneBottom - zoneHeight,
offset,
}
}
export function regionStorageKey(
region: DocsHeaderFooterRegion,
pageIndex: number,
differentFirstPage: boolean
): keyof DocPageSetup {
if (pageIndex === 0 && differentFirstPage) {
return region === "header" ? "headerFirstPage" : "footerFirstPage"
}
return region
}
export function buildRegionPatch(
setup: DocPageSetup,
region: DocsHeaderFooterRegion,
pageIndex: number,
content: Record<string, unknown>,
contentHeightPx: number
): Partial<DocPageSetup> {
const differentFirst = setup.headerFooterDifferentFirstPage ?? false
const key = regionStorageKey(region, pageIndex, differentFirst)
const topPx = mmToPx(setup.marginsMm.top)
const bottomPx = mmToPx(setup.marginsMm.bottom)
const headerOff =
setup.headerMarginMm != null ? mmToPx(setup.headerMarginMm) : 0
const footerOff =
setup.footerMarginMm != null ? mmToPx(setup.footerMarginMm) : 0
const defaultZonePx =
region === "header" ? Math.max(24, topPx - headerOff) : Math.max(24, bottomPx - footerOff)
const heightMm = pxToMm(Math.max(defaultZonePx, contentHeightPx))
return {
[key]: { content, heightMm },
}
}
export function toggleDifferentFirstPage(
setup: DocPageSetup,
enabled: boolean
): Partial<DocPageSetup> {
if (enabled) {
return {
headerFooterDifferentFirstPage: true,
headerFirstPage: setup.headerFirstPage ?? setup.header ?? null,
footerFirstPage: setup.footerFirstPage ?? setup.footer ?? null,
}
}
return { headerFooterDifferentFirstPage: false }
}

View File

@ -10,8 +10,9 @@ export type DocsShortcutId =
| "edit.pastePlain" | "edit.pastePlain"
| "edit.selectAll" | "edit.selectAll"
| "edit.findReplace" | "edit.findReplace"
| "view.showNonPrintable"
export type DocsShortcutCategory = "file" | "edit" export type DocsShortcutCategory = "file" | "edit" | "view"
/** Where the shortcut is handled. */ /** Where the shortcut is handled. */
export type DocsShortcutScope = "document" | "editor" export type DocsShortcutScope = "document" | "editor"
@ -124,6 +125,14 @@ export const DOCS_KEYBOARD_SHORTCUT_DEFINITIONS = [
handler: "custom", handler: "custom",
defaultBinding: { key: "h", mod: true, shift: true }, defaultBinding: { key: "h", mod: true, shift: true },
}, },
{
id: "view.showNonPrintable",
label: "Afficher les caractères non imprimables",
category: "view",
scope: "document",
handler: "custom",
defaultBinding: { key: "p", mod: true, shift: true },
},
] as const satisfies readonly DocsShortcutDefinition[] ] as const satisfies readonly DocsShortcutDefinition[]
export const DOCS_KEYBOARD_SHORTCUTS_BY_ID: Record< export const DOCS_KEYBOARD_SHORTCUTS_BY_ID: Record<

View File

@ -0,0 +1,33 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { countPageFlowSpacers } from "./extensions/docs-page-flow-decoration.ts"
describe("docs-page-flow spacers", () => {
const bodyAreaH = 900
const interPageSpacer = 200
it("inserts no spacer when content fits page 1", () => {
assert.equal(countPageFlowSpacers([{ height: 400 }], bodyAreaH, interPageSpacer), 0)
})
it("inserts one spacer when a block crosses page 1", () => {
assert.equal(
countPageFlowSpacers([{ height: 850 }, { height: 100 }], bodyAreaH, interPageSpacer),
1
)
})
it("does not insert spacers for blocks already past page 1 boundary", () => {
assert.equal(
countPageFlowSpacers([{ height: 40 }, { height: 40 }], bodyAreaH, interPageSpacer),
0
)
})
it("inserts one spacer per crossing block in a layout pass", () => {
assert.equal(
countPageFlowSpacers([{ height: 1300 }], bodyAreaH, interPageSpacer),
1
)
})
})

View File

@ -0,0 +1,8 @@
/** Gap between stacked pages in print layout (px, unscaled). */
export const DOCS_PAGE_GAP_PX = 12
export const DOCS_CANVAS_PADDING_Y_PX = 32
export const DOCS_CANVAS_PADDING_TOP_NARROW_PX = 0
export const DOCS_HORIZONTAL_RULER_HEIGHT_PX = 20
export const DOCS_VERTICAL_RULER_WIDTH_PX = 28

View File

@ -0,0 +1,36 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import {
computePageCount,
computePageMetrics,
computeProseMinHeight,
computeStackHeight,
} from "./docs-page-metrics.ts"
describe("docs-page-metrics", () => {
const metrics = computePageMetrics({
widthPx: 794,
heightPx: 1122,
marginsPx: { top: 96, right: 96, bottom: 96, left: 96 },
headerMarginMm: 12.7,
footerMarginMm: 12.7,
})
it("computes body area height from margins", () => {
assert.equal(metrics.bodyAreaHeight, 1122 - 96 - 96)
})
it("computes page count from content height", () => {
assert.equal(computePageCount(400, metrics), 1)
assert.equal(computePageCount(metrics.bodyAreaHeight + 1, metrics), 2)
})
it("computes stack height with page gaps", () => {
assert.equal(computeStackHeight(2, 1122), 1122 * 2 + 12)
})
it("computes prose min height with inter-page spacers", () => {
const minH = computeProseMinHeight(2, metrics)
assert.equal(minH, metrics.bodyAreaHeight * 2 + metrics.interPageSpacer)
})
})

View File

@ -0,0 +1,92 @@
import type { DocPageSetup } from "./doc-page-setup.ts"
import { DOCS_PAGE_GAP_PX } from "./docs-page-layout-constants.ts"
import { mmToPx } from "./doc-page-setup.ts"
export type DocsPageMetrics = {
pageWidth: number
pageHeight: number
margins: { top: number; right: number; bottom: number; left: number }
headerMarginPx: number
footerMarginPx: number
bodyAreaHeight: number
interPageSpacer: number
}
export function computePageMetrics(pageLayout: {
widthPx: number
heightPx: number
marginsPx: { top: number; right: number; bottom: number; left: number }
headerMarginMm?: number
footerMarginMm?: number
effectiveMarginsPx?: { top: number; right: number; bottom: number; left: number }
}): DocsPageMetrics {
const margins = pageLayout.effectiveMarginsPx ?? pageLayout.marginsPx
const headerMarginPx =
pageLayout.headerMarginMm != null
? mmToPx(pageLayout.headerMarginMm)
: pageLayout.marginsPx.top
const footerMarginPx =
pageLayout.footerMarginMm != null
? mmToPx(pageLayout.footerMarginMm)
: pageLayout.marginsPx.bottom
const bodyAreaHeight = pageLayout.heightPx - margins.top - margins.bottom
const interPageSpacer =
margins.bottom + DOCS_PAGE_GAP_PX + margins.top
return {
pageWidth: pageLayout.widthPx,
pageHeight: pageLayout.heightPx,
margins,
headerMarginPx,
footerMarginPx,
bodyAreaHeight: Math.max(1, bodyAreaHeight),
interPageSpacer,
}
}
/** Page count from prose content height (excludes outer surface padding). */
export function computePageCount(contentHeight: number, metrics: DocsPageMetrics): number {
if (contentHeight <= 0) return 1
const { bodyAreaHeight, interPageSpacer } = metrics
let remaining = contentHeight
let pages = 1
while (remaining > bodyAreaHeight) {
remaining -= bodyAreaHeight
if (remaining <= 0) break
remaining -= interPageSpacer
pages += 1
}
return pages
}
export function computeStackHeight(pageCount: number, pageHeight: number): number {
return pageCount * pageHeight + Math.max(0, pageCount - 1) * DOCS_PAGE_GAP_PX
}
/** Minimum prose height to align with visual page stack including inter-page spacers. */
export function computeProseMinHeight(pageCount: number, metrics: DocsPageMetrics): number {
const { bodyAreaHeight, interPageSpacer } = metrics
return (
pageCount * bodyAreaHeight + Math.max(0, pageCount - 1) * interPageSpacer
)
}
export function emptyRegionDoc() {
return { type: "doc", content: [{ type: "paragraph" }] }
}
export function pageSetupFromMetrics(
setup: DocPageSetup | null | undefined,
patch: Partial<DocPageSetup>
): DocPageSetup {
return { ...(setup ?? defaultMinimalSetup()), ...patch }
}
function defaultMinimalSetup(): DocPageSetup {
return {
widthMm: 210,
heightMm: 297,
marginsMm: { top: 25.4, right: 25.4, bottom: 25.4, left: 25.4 },
}
}

View File

@ -0,0 +1,73 @@
export const DOCS_RULER_MIN_MARGIN_PX = 12
export const DOCS_RULER_MIN_BODY_PX = 48
export type DocsRulerMarginSide = "left" | "right" | "top" | "bottom"
export type DocsPageMarginsPx = {
top: number
right: number
bottom: number
left: number
}
export function clampPageMarginPx(
side: DocsRulerMarginSide,
valuePx: number,
margins: DocsPageMarginsPx,
pageWidth: number,
pageHeight: number
): number {
const min = DOCS_RULER_MIN_MARGIN_PX
const bodyMin = DOCS_RULER_MIN_BODY_PX
switch (side) {
case "left":
return Math.round(
Math.max(min, Math.min(valuePx, pageWidth - margins.right - bodyMin))
)
case "right": {
const right = Math.round(
Math.max(min, Math.min(valuePx, pageWidth - margins.left - bodyMin))
)
return right
}
case "top":
return Math.round(
Math.max(min, Math.min(valuePx, pageHeight - margins.bottom - bodyMin))
)
case "bottom": {
const bottom = Math.round(
Math.max(min, Math.min(valuePx, pageHeight - margins.top - bodyMin))
)
return bottom
}
}
}
/** Horizontal ruler X → left margin px. */
export function pagePxFromHorizontalPointer(
pointerX: number,
rulerLeft: number,
scale: number
): number {
if (scale <= 0) return 0
return (pointerX - rulerLeft) / scale
}
/** Vertical ruler Y → distance from page top px. */
export function pagePxFromVerticalPointer(
pointerY: number,
rulerTop: number,
scale: number
): number {
if (scale <= 0) return 0
return (pointerY - rulerTop) / scale
}
export function mergeMarginPreview(
margins: DocsPageMarginsPx,
preview: Partial<DocsPageMarginsPx> | null | undefined
): DocsPageMarginsPx {
if (!preview) return margins
return { ...margins, ...preview }
}

View File

@ -0,0 +1,26 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { clampPageMarginPx } from "./docs-ruler-margin-math.ts"
describe("docs-ruler-margin-math", () => {
const pageWidth = 794
const pageHeight = 1122
const margins = { top: 96, right: 96, bottom: 96, left: 96 }
it("clamps left margin against right margin and min body", () => {
assert.equal(clampPageMarginPx("left", 400, margins, pageWidth, pageHeight), 400)
assert.equal(clampPageMarginPx("left", 700, margins, pageWidth, pageHeight), 650)
assert.equal(clampPageMarginPx("left", 0, margins, pageWidth, pageHeight), 12)
})
it("clamps right margin from pointer position", () => {
assert.equal(clampPageMarginPx("right", 120, margins, pageWidth, pageHeight), 120)
assert.equal(clampPageMarginPx("right", 700, margins, pageWidth, pageHeight), 650)
})
it("clamps top and bottom margins", () => {
assert.equal(clampPageMarginPx("top", 48, margins, pageWidth, pageHeight), 48)
assert.equal(clampPageMarginPx("bottom", 80, margins, pageWidth, pageHeight), 80)
assert.equal(clampPageMarginPx("top", 900, margins, pageWidth, pageHeight), 978)
})
})

View File

@ -0,0 +1,65 @@
import type { PageFormatId } from "@/lib/drive/page-formats"
import {
formatRulerMajorLabel,
getRulerUnitConfig,
} from "@/lib/drive/docs-ruler-units"
export type DocsRulerTick = {
/** Position in page px. */
pos: number
major: boolean
label?: string
}
function buildRulerTicksAlongAxis(
lengthPx: number,
formatId: PageFormatId,
originOffsetPx = 0
): DocsRulerTick[] {
const { unit, majorStepPx, minorDivisions } = getRulerUnitConfig(formatId)
const minorStep = majorStepPx / minorDivisions
const ticks: DocsRulerTick[] = []
const startMajor = -Math.ceil(originOffsetPx / majorStepPx)
const endMajor = Math.ceil(lengthPx / majorStepPx)
for (let major = startMajor; major <= endMajor; major += 1) {
const pos = major * majorStepPx
if (pos > lengthPx + 0.5) break
const unitValue = major
ticks.push({
pos,
major: true,
label:
unitValue !== 0 || originOffsetPx > 0
? formatRulerMajorLabel(unitValue, unit)
: undefined,
})
if (pos < 0) continue
for (let minor = 1; minor < minorDivisions; minor += 1) {
const tickPos = pos + minor * minorStep
if (tickPos > lengthPx) break
ticks.push({ pos: tickPos, major: false })
}
}
return ticks
}
export function buildHorizontalRulerTicks(
lengthPx: number,
formatId: PageFormatId
): DocsRulerTick[] {
return buildRulerTicksAlongAxis(lengthPx, formatId, 0)
}
export function buildVerticalRulerTicks(
pageHeightPx: number,
topMarginPx: number,
formatId: PageFormatId
): DocsRulerTick[] {
return buildRulerTicksAlongAxis(pageHeightPx, formatId, topMarginPx)
}

View File

@ -0,0 +1,17 @@
import { MM_TO_PX } from "@/lib/drive/page-formats"
/** One inch in page coordinate space (matches CSS print px). */
export const DOCS_RULER_INCH_PX = MM_TO_PX * 25.4
export function docsZoomToScale(zoom: number): number {
return zoom / 100
}
export function docsPageLengthToScreen(lengthPx: number, scale: number): number {
return lengthPx * scale
}
export function docsScreenLengthToPage(lengthPx: number, scale: number): number {
if (scale <= 0) return lengthPx
return lengthPx / scale
}

View File

@ -0,0 +1,32 @@
/** Page containing the vertical center of the canvas viewport (0-based). */
export function resolveCurrentPageInViewport(
stackTopInViewport: number,
viewportHeight: number,
pageStride: number,
pageCount: number
): number {
if (pageCount <= 1 || pageStride <= 0 || viewportHeight <= 0) return 0
const centerInStack = viewportHeight / 2 - stackTopInViewport
const index = Math.floor(centerInStack / pageStride)
return Math.min(pageCount - 1, Math.max(0, index))
}
/** @deprecated Use resolveCurrentPageInViewport — kept for tests comparing old behaviour. */
export function resolveCurrentPageAtViewportTop(
stackTopInViewport: number,
pageStride: number,
pageCount: number
): number {
if (pageCount <= 1 || pageStride <= 0) return 0
if (stackTopInViewport >= 0) return 0
const index = Math.floor(-stackTopInViewport / pageStride)
return Math.min(pageCount - 1, Math.max(0, index))
}
export function computePageTopInViewport(
stackTopInViewport: number,
currentPage: number,
pageStride: number
): number {
return stackTopInViewport + currentPage * pageStride
}

View File

@ -0,0 +1,37 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import {
computePageTopInViewport,
resolveCurrentPageAtViewportTop,
resolveCurrentPageInViewport,
} from "./docs-ruler-sync-math.ts"
describe("docs ruler vertical sync", () => {
const stride = 1000
const viewport = 800
it("keeps page 0 while viewport center is on page 1", () => {
assert.equal(resolveCurrentPageInViewport(48, viewport, stride, 3), 0)
assert.equal(computePageTopInViewport(48, 0, stride), 48)
})
it("advances page when viewport center crosses page boundary", () => {
// centerInStack = 400 - (-700) = 1100 → page 1
assert.equal(resolveCurrentPageInViewport(-700, viewport, stride, 3), 1)
assert.equal(computePageTopInViewport(-700, 1, stride), 300)
// Old top-edge rule still on page 0 here
assert.equal(resolveCurrentPageAtViewportTop(-700, stride, 3), 0)
})
it("switches before page 1 fully leaves viewport (vs top-edge rule)", () => {
// centerInStack = 400 - (-750) = 1150 → page 1; page 1 bottom still visible
assert.equal(resolveCurrentPageInViewport(-750, viewport, stride, 2), 1)
assert.equal(resolveCurrentPageAtViewportTop(-750, stride, 2), 0)
})
it("clamps to last page", () => {
assert.equal(resolveCurrentPageInViewport(-5000, viewport, stride, 3), 2)
assert.equal(computePageTopInViewport(-5000, 2, stride), -3000)
})
})

View File

@ -0,0 +1,58 @@
import { MM_TO_PX, type PageFormatId } from "@/lib/drive/page-formats"
import { DOCS_RULER_INCH_PX } from "@/lib/drive/docs-ruler-scale"
export type DocsRulerUnit = "cm" | "inch"
/** One centimetre in page coordinate space. */
export const DOCS_RULER_CM_PX = MM_TO_PX * 10
const INCH_FORMAT_IDS = new Set<PageFormatId>(["letter", "legal", "tabloid"])
const CM_FORMAT_IDS = new Set<PageFormatId>(["a4", "a5"])
export function resolveRulerUnit(formatId: PageFormatId): DocsRulerUnit {
if (INCH_FORMAT_IDS.has(formatId)) return "inch"
if (CM_FORMAT_IDS.has(formatId)) return "cm"
return "cm"
}
export type DocsRulerUnitConfig = {
unit: DocsRulerUnit
majorStepPx: number
minorDivisions: number
}
export function getRulerUnitConfig(formatId: PageFormatId): DocsRulerUnitConfig {
const unit = resolveRulerUnit(formatId)
if (unit === "inch") {
return {
unit,
majorStepPx: DOCS_RULER_INCH_PX,
minorDivisions: 8,
}
}
return {
unit,
majorStepPx: DOCS_RULER_CM_PX,
minorDivisions: 10,
}
}
export function formatRulerMajorLabel(value: number, unit: DocsRulerUnit): string {
if (value === 0) return "0"
const abs = Math.abs(value)
if (unit === "cm") {
return Number.isInteger(abs) ? String(value) : value.toFixed(1).replace(".", ",")
}
return Number.isInteger(abs) ? String(value) : String(Math.round(value * 10) / 10)
}
/** Margin distance from page edge, in the ruler unit for the page format. */
export function formatMarginDistanceLabel(
marginPx: number,
formatId: PageFormatId
): string {
const { unit, majorStepPx } = getRulerUnitConfig(formatId)
const value = marginPx / majorStepPx
const formatted = formatRulerMajorLabel(value, unit)
return unit === "inch" ? `${formatted}"` : `${formatted} cm`
}

View File

@ -0,0 +1,43 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { buildHorizontalRulerTicks } from "@/lib/drive/docs-ruler-math"
import { getRulerUnitConfig, resolveRulerUnit, formatMarginDistanceLabel } from "@/lib/drive/docs-ruler-units"
import { MM_TO_PX } from "@/lib/drive/page-formats"
describe("docs ruler units", () => {
it("uses cm for A4 and inches for Letter", () => {
assert.equal(resolveRulerUnit("a4"), "cm")
assert.equal(resolveRulerUnit("a5"), "cm")
assert.equal(resolveRulerUnit("letter"), "inch")
assert.equal(resolveRulerUnit("legal"), "inch")
assert.equal(resolveRulerUnit("tabloid"), "inch")
})
it("builds cm ticks for A4 width", () => {
const widthPx = Math.round(210 * MM_TO_PX)
const ticks = buildHorizontalRulerTicks(widthPx, "a4")
const majors = ticks.filter((t) => t.major && t.label)
assert.equal(majors[0]?.label, "1")
assert.equal(majors.at(-1)?.label, "21")
assert.equal(getRulerUnitConfig("a4").majorStepPx, MM_TO_PX * 10)
})
it("builds inch ticks for Letter width", () => {
const widthPx = Math.round(216 * MM_TO_PX)
const ticks = buildHorizontalRulerTicks(widthPx, "letter")
const majors = ticks.filter((t) => t.major && t.label)
assert.equal(majors[0]?.label, "1")
assert.equal(majors.length, 8)
})
it("formats margin tooltip in cm for A4", () => {
const cmStep = MM_TO_PX * 10
assert.equal(formatMarginDistanceLabel(cmStep * 2.5, "a4"), "2,5 cm")
assert.equal(formatMarginDistanceLabel(cmStep * 2, "a4"), "2 cm")
})
it("formats margin tooltip in inches for Letter", () => {
const inchPx = MM_TO_PX * 25.4
assert.equal(formatMarginDistanceLabel(inchPx * 1.5, "letter"), '1.5"')
})
})

View File

@ -6,11 +6,22 @@ import {
type PageFormatId, type PageFormatId,
} from "@/lib/drive/page-formats" } from "@/lib/drive/page-formats"
export type DocsEditorMode = "edit" | "suggest" | "view"
export type DocsCommentsDisplay = "hidden" | "collapsed" | "expanded" | "all"
export type DocsViewSettings = { export type DocsViewSettings = {
pageFormatId: PageFormatId pageFormatId: PageFormatId
zoom: number zoom: number
spellcheck: boolean spellcheck: boolean
chromeCollapsed: boolean chromeCollapsed: boolean
editorMode: DocsEditorMode
commentsDisplay: DocsCommentsDisplay
outlineSidebarExpanded: boolean
showLayout: boolean
showRuler: boolean
showEquationToolbar: boolean
showNonPrintableChars: boolean
} }
const STORAGE_KEY = "ultidrive-docs-view-settings" const STORAGE_KEY = "ultidrive-docs-view-settings"
@ -20,6 +31,13 @@ const DEFAULT_SETTINGS: DocsViewSettings = {
zoom: 100, zoom: 100,
spellcheck: true, spellcheck: true,
chromeCollapsed: false, chromeCollapsed: false,
editorMode: "edit",
commentsDisplay: "expanded",
outlineSidebarExpanded: false,
showLayout: true,
showRuler: true,
showEquationToolbar: false,
showNonPrintableChars: false,
} }
const ZOOM_MIN = 50 const ZOOM_MIN = 50
@ -41,6 +59,16 @@ function readSettings(): DocsViewSettings {
zoom: clampZoom(parsed.zoom ?? DEFAULT_SETTINGS.zoom), zoom: clampZoom(parsed.zoom ?? DEFAULT_SETTINGS.zoom),
spellcheck: parsed.spellcheck ?? DEFAULT_SETTINGS.spellcheck, spellcheck: parsed.spellcheck ?? DEFAULT_SETTINGS.spellcheck,
chromeCollapsed: parsed.chromeCollapsed ?? DEFAULT_SETTINGS.chromeCollapsed, chromeCollapsed: parsed.chromeCollapsed ?? DEFAULT_SETTINGS.chromeCollapsed,
editorMode: parsed.editorMode ?? DEFAULT_SETTINGS.editorMode,
commentsDisplay: parsed.commentsDisplay ?? DEFAULT_SETTINGS.commentsDisplay,
outlineSidebarExpanded:
parsed.outlineSidebarExpanded ?? DEFAULT_SETTINGS.outlineSidebarExpanded,
showLayout: parsed.showLayout ?? DEFAULT_SETTINGS.showLayout,
showRuler: parsed.showRuler ?? DEFAULT_SETTINGS.showRuler,
showEquationToolbar:
parsed.showEquationToolbar ?? DEFAULT_SETTINGS.showEquationToolbar,
showNonPrintableChars:
parsed.showNonPrintableChars ?? DEFAULT_SETTINGS.showNonPrintableChars,
} }
} catch { } catch {
return DEFAULT_SETTINGS return DEFAULT_SETTINGS
@ -99,6 +127,62 @@ export function useDocsViewSettings() {
}) })
}, []) }, [])
const setEditorMode = useCallback((editorMode: DocsEditorMode) => {
setSettings((prev) => {
const next = { ...prev, editorMode }
writeSettings(next)
return next
})
}, [])
const setCommentsDisplay = useCallback((commentsDisplay: DocsCommentsDisplay) => {
setSettings((prev) => {
const next = { ...prev, commentsDisplay }
writeSettings(next)
return next
})
}, [])
const toggleOutlineSidebarExpanded = useCallback(() => {
setSettings((prev) => {
const next = { ...prev, outlineSidebarExpanded: !prev.outlineSidebarExpanded }
writeSettings(next)
return next
})
}, [])
const toggleShowLayout = useCallback(() => {
setSettings((prev) => {
const next = { ...prev, showLayout: !prev.showLayout }
writeSettings(next)
return next
})
}, [])
const toggleShowRuler = useCallback(() => {
setSettings((prev) => {
const next = { ...prev, showRuler: !prev.showRuler }
writeSettings(next)
return next
})
}, [])
const toggleShowEquationToolbar = useCallback(() => {
setSettings((prev) => {
const next = { ...prev, showEquationToolbar: !prev.showEquationToolbar }
writeSettings(next)
return next
})
}, [])
const toggleShowNonPrintableChars = useCallback(() => {
setSettings((prev) => {
const next = { ...prev, showNonPrintableChars: !prev.showNonPrintableChars }
writeSettings(next)
return next
})
}, [])
return { return {
settings, settings,
setPageFormatId, setPageFormatId,
@ -106,6 +190,13 @@ export function useDocsViewSettings() {
setSpellcheck, setSpellcheck,
toggleSpellcheck, toggleSpellcheck,
toggleChromeCollapsed, toggleChromeCollapsed,
setEditorMode,
setCommentsDisplay,
toggleOutlineSidebarExpanded,
toggleShowLayout,
toggleShowRuler,
toggleShowEquationToolbar,
toggleShowNonPrintableChars,
zoomMin: ZOOM_MIN, zoomMin: ZOOM_MIN,
zoomMax: ZOOM_MAX, zoomMax: ZOOM_MAX,
} }

View File

@ -0,0 +1,326 @@
import type { TipTapJSON } from "@/lib/drive/richtext-import"
import {
buildGradientCss,
DOCS_GRAPHIC_DEFAULTS,
type DocsShapeType,
} from "./docs-graphic-types.ts"
import {
parseVmlColorForDrawing,
parseRotationDegFromStyle,
} from "./docx-drawing-vml.ts"
import { resolveDocxMediaDataUrl } from "./doc-page-background.ts"
type DocxArchive = Record<string, Uint8Array>
export type DocxBodyDrawing = {
graphicType: "image" | "shape" | "gradient"
src?: string
shapeType?: DocsShapeType
fill?: string
stroke?: string
strokeWidth?: number
gradientCss?: string
gradientAngle?: number
gradientColor1?: string
gradientColor2?: string
width: number
height: number
x: number
y: number
rotationDeg: number
wrap: "square" | "behind" | "in-front" | "inline"
placement: "block" | "absolute" | "inline"
}
const EMU_PER_PX = 914400 / 96
function decodeXml(bytes: Uint8Array | undefined): string {
if (!bytes) return ""
return new TextDecoder().decode(bytes)
}
function emuToPx(emu: number): number {
return Math.round(emu / EMU_PER_PX)
}
function readIntAttr(fragment: string, name: string): number | null {
const match = fragment.match(new RegExp(`\\b${name}="(-?\\d+)"`, "i"))
if (!match) return null
const value = Number.parseInt(match[1] ?? "", 10)
return Number.isFinite(value) ? value : null
}
function parsePresetShape(prst: string | undefined): DocsShapeType {
switch (prst) {
case "ellipse":
case "oval":
return "ellipse"
case "line":
case "straightConnector1":
return "line"
case "rightArrow":
case "leftArrow":
case "bentArrow":
return "arrow"
default:
return "rect"
}
}
function parseDrawingXml(
block: string,
archive: DocxArchive,
relsPath: string
): DocxBodyDrawing | null {
const extent = block.match(/<wp:extent\b[^>]*\/?>/i)?.[0]
const cx = extent ? readIntAttr(extent, "cx") : null
const cy = extent ? readIntAttr(extent, "cy") : null
const width = cx != null ? Math.max(24, emuToPx(cx)) : 240
const height = cy != null ? Math.max(24, emuToPx(cy)) : 160
let x = 0
let y = 0
const posH = block.match(/<wp:positionH\b[^>]*>[\s\S]*?<\/wp:positionH>/i)?.[0]
const posV = block.match(/<wp:positionV\b[^>]*>[\s\S]*?<\/wp:positionV>/i)?.[0]
const posOffsetH = posH?.match(/<wp:posOffset>(-?\d+)<\/wp:posOffset>/i)?.[1]
const posOffsetV = posV?.match(/<wp:posOffset>(-?\d+)<\/wp:posOffset>/i)?.[1]
if (posOffsetH) x = emuToPx(Number(posOffsetH))
if (posOffsetV) y = emuToPx(Number(posOffsetV))
const rot = block.match(/\brot="(-?\d+)"/i)?.[1]
const rotationDeg = rot ? Math.round(Number(rot) / 60000) : 0
const behindDoc = /<wp:anchor\b[^>]*\bbehindDoc="1"/i.test(block)
const inline = /<wp:inline\b/i.test(block)
const wrap = inline ? "inline" : behindDoc ? "behind" : "square"
const placement = inline ? "inline" : "absolute"
const blipEmbed = block.match(/<a:blip\b[^>]*\bembed="([^"]+)"/i)?.[1]
if (blipEmbed) {
const src = resolveDocxMediaDataUrl(archive, relsPath, blipEmbed)
if (src) {
return {
graphicType: "image",
src,
width,
height,
x,
y,
rotationDeg,
wrap,
placement,
}
}
}
const prstGeom = block.match(/<a:prstGeom\b[^>]*\bprst="([^"]+)"/i)?.[1]
const solidFill = block.match(/<a:solidFill\b[^>]*>[\s\S]*?<\/a:solidFill>/i)?.[0]
const gradFill = block.match(/<a:gradFill\b[^>]*>[\s\S]*?<\/a:gradFill>/i)?.[0]
const ln = block.match(/<a:ln\b[^>]*>[\s\S]*?<\/a:ln>/i)?.[0] ?? block.match(/<a:ln\b[^>]*\/?>/i)?.[0]
if (gradFill) {
const stop1 = gradFill.match(/<a:srgbClr\b[^>]*\bval="([^"]+)"/i)?.[1]
const stop2 = [...gradFill.matchAll(/<a:srgbClr\b[^>]*\bval="([^"]+)"/gi)][1]?.[1]
const color1 = stop1 ? `#${stop1}` : "#4285f4"
const color2 = stop2 ? `#${stop2}` : "#34a853"
const angle = readIntAttr(gradFill, "ang") ?? 1800000
const gradientAngle = Math.round(angle / 60000)
return {
graphicType: "gradient",
gradientCss: buildGradientCss(gradientAngle, color1, color2),
gradientAngle,
gradientColor1: color1,
gradientColor2: color2,
width,
height,
x,
y,
rotationDeg,
wrap,
placement,
}
}
if (prstGeom || solidFill) {
const srgb = solidFill?.match(/<a:srgbClr\b[^>]*\bval="([^"]+)"/i)?.[1]
const fill = srgb ? `#${srgb}` : "#4285f4"
const strokeClr = ln?.match(/<a:srgbClr\b[^>]*\bval="([^"]+)"/i)?.[1]
const stroke = strokeClr ? `#${strokeClr}` : "#1a73e8"
const strokeWidth = ln ? Math.max(1, emuToPx(readIntAttr(ln, "w") ?? 12700)) : 2
return {
graphicType: "shape",
shapeType: parsePresetShape(prstGeom),
fill,
stroke,
strokeWidth,
width,
height,
x,
y,
rotationDeg,
wrap,
placement,
}
}
return null
}
function parseVmlShape(
shapeXml: string,
archive: DocxArchive,
relsPath: string
): DocxBodyDrawing | null {
const style = shapeXml.match(/\bstyle="([^"]+)"/i)?.[1] ?? ""
const widthMatch = style.match(/\bwidth:\s*([\d.]+)pt/i)
const heightMatch = style.match(/\bheight:\s*([\d.]+)pt/i)
const width = widthMatch ? Math.round(Number(widthMatch[1]) * 96 / 72) : 240
const height = heightMatch ? Math.round(Number(heightMatch[1]) * 96 / 72) : 160
const leftMatch = style.match(/\bleft:\s*([\d.]+)pt/i)
const topMatch = style.match(/\btop:\s*([\d.]+)pt/i)
const x = leftMatch ? Math.round(Number(leftMatch[1]) * 96 / 72) : 0
const y = topMatch ? Math.round(Number(topMatch[1]) * 96 / 72) : 0
const fill = parseVmlColorForDrawing(shapeXml.match(/\bfillcolor="([^"]+)"/i)?.[1])
const stroke = parseVmlColorForDrawing(shapeXml.match(/\bstrokecolor="([^"]+)"/i)?.[1])
const rotationDeg = parseRotationDegFromStyle(style)
const imagedata = shapeXml.match(/<v:imagedata\b[^>]*\br:id="([^"]+)"/i)?.[1]
if (imagedata) {
const src = resolveDocxMediaDataUrl(archive, relsPath, imagedata)
if (src) {
return {
graphicType: "image",
src,
width,
height,
x,
y,
rotationDeg,
wrap: "square",
placement: "absolute",
}
}
}
const type = shapeXml.match(/\btype="[^"]*#([^"]+)"/i)?.[1]?.toLowerCase()
let shapeType: DocsShapeType = "rect"
if (type?.includes("oval") || type?.includes("ellipse")) shapeType = "ellipse"
else if (type?.includes("line")) shapeType = "line"
if (fill || stroke) {
return {
graphicType: "shape",
shapeType,
fill: fill ?? "#4285f4",
stroke: stroke ?? "#1a73e8",
strokeWidth: 2,
width,
height,
x,
y,
rotationDeg,
wrap: "square",
placement: "absolute",
}
}
return null
}
export function extractBodyDrawingsFromDocx(archive: DocxArchive): DocxBodyDrawing[] {
const documentXml = decodeXml(archive["word/document.xml"])
if (!documentXml) return []
const relsPath = "word/_rels/document.xml.rels"
const drawings: DocxBodyDrawing[] = []
for (const match of documentXml.matchAll(/<w:drawing\b[^>]*>[\s\S]*?<\/w:drawing>/gi)) {
const parsed = parseDrawingXml(match[0], archive, relsPath)
if (parsed?.graphicType !== "image") {
if (parsed) drawings.push(parsed)
}
}
for (const match of documentXml.matchAll(/<v:shape\b[^>]*>[\s\S]*?<\/v:shape>/gi)) {
const parsed = parseVmlShape(match[0], archive, relsPath)
if (parsed) drawings.push(parsed)
}
return drawings
}
function drawingToGraphicNode(drawing: DocxBodyDrawing): TipTapJSON {
return {
type: "docsGraphic",
attrs: {
...DOCS_GRAPHIC_DEFAULTS,
graphicType: drawing.graphicType,
src: drawing.src ?? null,
shapeType: drawing.shapeType ?? "rect",
fill: drawing.fill ?? DOCS_GRAPHIC_DEFAULTS.fill,
stroke: drawing.stroke ?? DOCS_GRAPHIC_DEFAULTS.stroke,
strokeWidth: drawing.strokeWidth ?? 2,
gradientCss: drawing.gradientCss ?? "",
gradientAngle: drawing.gradientAngle ?? 180,
gradientColor1: drawing.gradientColor1 ?? DOCS_GRAPHIC_DEFAULTS.gradientColor1,
gradientColor2: drawing.gradientColor2 ?? DOCS_GRAPHIC_DEFAULTS.gradientColor2,
width: drawing.width,
height: drawing.height,
x: drawing.x,
y: drawing.y,
rotationDeg: drawing.rotationDeg,
wrap: drawing.wrap,
placement: drawing.placement,
floatSide: "left",
},
}
}
function countGraphicImages(content: TipTapJSON): number {
let count = 0
const walk = (node: TipTapJSON) => {
if (
node.type === "image" ||
(node.type === "docsGraphic" &&
(node.attrs as { graphicType?: string })?.graphicType === "image")
) {
count++
}
if (Array.isArray(node.content)) {
node.content.forEach((child) => {
if (child && typeof child === "object") walk(child as TipTapJSON)
})
}
}
walk(content)
return count
}
/** Insert shape/gradient drawings not already represented as image nodes. */
export async function enrichContentFromDocxDrawings(
buffer: ArrayBuffer,
content: TipTapJSON
): Promise<TipTapJSON> {
try {
const { unzipSync } = await import("fflate")
const archive = unzipSync(new Uint8Array(buffer)) as DocxArchive
const drawings = extractBodyDrawingsFromDocx(archive)
const nonImageDrawings = drawings.filter((d) => d.graphicType !== "image")
if (nonImageDrawings.length === 0) return content
const existingImages = countGraphicImages(content)
const toInsert = nonImageDrawings.slice(Math.max(0, existingImages))
if (toInsert.length === 0) return content
const contentArray = Array.isArray(content.content) ? [...content.content] : []
for (const drawing of toInsert) {
contentArray.push(drawingToGraphicNode(drawing))
}
return { ...content, content: contentArray }
} catch {
return content
}
}

View File

@ -0,0 +1,12 @@
export function parseVmlColorForDrawing(raw: string | undefined): string | undefined {
if (!raw) return undefined
const trimmed = raw.trim()
if (trimmed.startsWith("#") && trimmed.length === 7) return trimmed
if (/^[0-9a-f]{6}$/i.test(trimmed)) return `#${trimmed}`
return undefined
}
export function parseRotationDegFromStyle(style: string): number {
const match = style.match(/\brotation:\s*(-?\d+)/i)
return match ? Number(match[1]) : 0
}

View File

@ -0,0 +1,96 @@
import type { TipTapJSON } from "@/lib/drive/richtext-import"
import type { DocPageHeaderFooter, DocPageSetup } from "@/lib/drive/doc-page-setup"
type DocxArchive = Record<string, Uint8Array>
function decodeXml(bytes: Uint8Array | undefined): string {
if (!bytes) return ""
return new TextDecoder().decode(bytes)
}
function xmlTextToParagraphs(xml: string): TipTapJSON[] {
const paragraphs: TipTapJSON[] = []
const pBlocks = [...xml.matchAll(/<w:p\b[^>]*>[\s\S]*?<\/w:p>/gi)]
if (pBlocks.length === 0) {
const text = xml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()
if (text) {
paragraphs.push({ type: "paragraph", content: [{ type: "text", text }] })
}
return paragraphs
}
for (const pMatch of pBlocks) {
const pXml = pMatch[0]
const runs = [...pXml.matchAll(/<w:t\b[^>]*>([^<]*)<\/w:t>/gi)]
const text = runs.map((r) => r[1] ?? "").join("")
const content: TipTapJSON[] = []
if (text) {
const marks: TipTapJSON[] = []
if (/<w:b\b[^>]*\/>|<w:b\b[^>]*>/.test(pXml)) marks.push({ type: "bold" })
if (/<w:i\b[^>]*\/>|<w:i\b[^>]*>/.test(pXml)) marks.push({ type: "italic" })
if (/<w:u\b[^>]*\/>|<w:u\b[^>]*>/.test(pXml)) marks.push({ type: "underline" })
content.push({ type: "text", text, ...(marks.length ? { marks } : {}) })
}
paragraphs.push({ type: "paragraph", content })
}
return paragraphs
}
function parseHeaderFooterPart(
archive: DocxArchive,
partName: string
): DocPageHeaderFooter | null {
const xml = decodeXml(archive[`word/${partName}.xml`])
if (!xml) return null
const contentBlocks = xmlTextToParagraphs(xml)
if (contentBlocks.length === 0) return null
return {
content: { type: "doc", content: contentBlocks },
heightMm: 15,
}
}
export type DocxHeaderFooterResult = Pick<
DocPageSetup,
"header" | "footer" | "headerFooterDifferentFirstPage"
>
/** Extract header/footer body content from DOCX archive. */
export async function extractDocxHeaderFooter(
buffer: ArrayBuffer
): Promise<DocxHeaderFooterResult> {
try {
const { unzipSync } = await import("fflate")
const archive = unzipSync(new Uint8Array(buffer)) as DocxArchive
const header =
parseHeaderFooterPart(archive, "header1") ??
parseHeaderFooterPart(archive, "header2")
const footer =
parseHeaderFooterPart(archive, "footer1") ??
parseHeaderFooterPart(archive, "footer2")
const documentXml = decodeXml(archive["word/document.xml"])
const differentFirst =
documentXml != null &&
/<w:titlePg\b/i.test(documentXml)
return {
header: header ?? null,
footer: footer ?? null,
headerFooterDifferentFirstPage: differentFirst,
}
} catch {
return {
header: null,
footer: null,
headerFooterDifferentFirstPage: false,
}
}
}

View File

@ -0,0 +1,223 @@
import type { TipTapJSON } from "@/lib/drive/richtext-import"
import type { DocsGraphicFloatSide, DocsGraphicPlacement, DocsGraphicWrap } from "./docs-graphic-types.ts"
type DocxArchive = Record<string, Uint8Array>
const EMU_PER_PX = 914400 / 96
export type DocxGraphicPosition = {
width: number
height: number
x: number
y: number
placement: DocsGraphicPlacement
wrap: DocsGraphicWrap
floatSide: DocsGraphicFloatSide
rotationDeg: number
zIndex: number
behindDoc: boolean
cropX?: number
cropY?: number
cropWidth?: number
cropHeight?: number
}
function decodeXml(bytes: Uint8Array | undefined): string {
if (!bytes) return ""
return new TextDecoder().decode(bytes)
}
function emuToPx(emu: number): number {
return Math.round(emu / EMU_PER_PX)
}
function readIntAttr(fragment: string, name: string): number | null {
const match = fragment.match(new RegExp(`\\b${name}="(-?\\d+)"`, "i"))
if (!match) return null
const value = Number.parseInt(match[1] ?? "", 10)
return Number.isFinite(value) ? value : null
}
function parseWrapType(anchorXml: string): DocsGraphicWrap {
if (/<wp:wrapNone\b/i.test(anchorXml)) return "top-bottom"
if (/<wp:wrapTopAndBottom\b/i.test(anchorXml)) return "top-bottom"
if (/<wp:wrapTight\b/i.test(anchorXml)) return "tight"
if (/<wp:wrapThrough\b/i.test(anchorXml)) return "through"
if (/<wp:wrapSquare\b/i.test(anchorXml)) return "square"
if (/<wp:wrapNone\b/i.test(anchorXml)) return "square"
return "square"
}
function parseFloatSide(anchorXml: string): DocsGraphicFloatSide {
const align = anchorXml.match(/<wp:positionH\b[^>]*>[\s\S]*?<wp:align>(\w+)<\/wp:align>/i)?.[1]
if (align === "right") return "right"
if (align === "center") return "center"
return "left"
}
function parseRotation(drawingXml: string): number {
const rot = drawingXml.match(/\brot="(-?\d+)"/i)?.[1]
if (!rot) return 0
// OOXML rotation is in 60000ths of a degree
return Math.round(Number(rot) / 60000)
}
function parseCropFromBlip(blipXml: string): Pick<
DocxGraphicPosition,
"cropX" | "cropY" | "cropWidth" | "cropHeight"
> | null {
const srcRect = blipXml.match(/<a:srcRect\b[^>]*\/?>/i)?.[0]
if (!srcRect) return null
const l = readIntAttr(srcRect, "l") ?? 0
const t = readIntAttr(srcRect, "t") ?? 0
const r = readIntAttr(srcRect, "r") ?? 0
const b = readIntAttr(srcRect, "b") ?? 0
if (l === 0 && t === 0 && r === 0 && b === 0) return null
const cropX = l / 100000
const cropY = t / 100000
const cropWidth = 1 - (l + r) / 100000
const cropHeight = 1 - (t + b) / 100000
return { cropX, cropY, cropWidth, cropHeight }
}
function parseDrawingBlock(block: string, inline: boolean): DocxGraphicPosition | null {
const extent = block.match(/<wp:extent\b[^>]*\/?>/i)?.[0]
if (!extent) return null
const cx = readIntAttr(extent, "cx")
const cy = readIntAttr(extent, "cy")
if (cx == null || cy == null) return null
const behindDoc = /<wp:anchor\b[^>]*\bbehindDoc="1"/i.test(block)
const inFront = /<wp:anchor\b[^>]*\blayoutInCell="0"/i.test(block) && !behindDoc
let x = 0
let y = 0
if (!inline) {
const posH = block.match(/<wp:positionH\b[^>]*>[\s\S]*?<\/wp:positionH>/i)?.[0]
const posV = block.match(/<wp:positionV\b[^>]*>[\s\S]*?<\/wp:positionV>/i)?.[0]
const posOffsetH = posH?.match(/<wp:posOffset>(-?\d+)<\/wp:posOffset>/i)?.[1]
const posOffsetV = posV?.match(/<wp:posOffset>(-?\d+)<\/wp:posOffset>/i)?.[1]
if (posOffsetH) x = emuToPx(Number(posOffsetH))
if (posOffsetV) y = emuToPx(Number(posOffsetV))
}
const wrap = inline ? "inline" : behindDoc ? "behind" : inFront ? "in-front" : parseWrapType(block)
const placement: DocsGraphicPlacement = inline ? "inline" : "absolute"
const blip = block.match(/<a:blip\b[^>]*\/?>/i)?.[0] ?? ""
const crop = parseCropFromBlip(blip)
return {
width: Math.max(24, emuToPx(cx)),
height: Math.max(24, emuToPx(cy)),
x,
y,
placement,
wrap,
floatSide: inline ? "left" : parseFloatSide(block),
rotationDeg: parseRotation(block),
zIndex: behindDoc ? 0 : inFront ? 20 : 1,
behindDoc,
...crop,
}
}
/** Extract ordered graphic positions from word/document.xml. */
export function extractGraphicPositionsFromDocx(archive: DocxArchive): DocxGraphicPosition[] {
const documentXml = decodeXml(archive["word/document.xml"])
if (!documentXml) return []
const positions: DocxGraphicPosition[] = []
const drawingPattern = /<w:drawing\b[^>]*>[\s\S]*?<\/w:drawing>/gi
for (const match of documentXml.matchAll(drawingPattern)) {
const block = match[0]
const inline = /<wp:inline\b/i.test(block)
const anchor = /<wp:anchor\b/i.test(block)
if (!inline && !anchor) continue
const parsed = parseDrawingBlock(block, inline)
if (parsed) positions.push(parsed)
}
return positions
}
function isGraphicNode(node: TipTapJSON): boolean {
return (
node.type === "image" ||
node.type === "docsGraphic" ||
node.type === "docsInlineGraphic"
)
}
function applyPositionToNode(node: TipTapJSON, pos: DocxGraphicPosition): TipTapJSON {
const attrs = (node.attrs as Record<string, unknown>) ?? {}
return {
...node,
attrs: {
...attrs,
width: pos.width,
height: pos.height,
x: pos.x,
y: pos.y,
placement: pos.placement,
wrap: pos.wrap,
floatSide: pos.floatSide,
rotationDeg: pos.rotationDeg,
zIndex: pos.zIndex,
...(pos.cropX != null ? { cropX: pos.cropX } : {}),
...(pos.cropY != null ? { cropY: pos.cropY } : {}),
...(pos.cropWidth != null ? { cropWidth: pos.cropWidth } : {}),
...(pos.cropHeight != null ? { cropHeight: pos.cropHeight } : {}),
},
}
}
function collectGraphicNodes(content: TipTapJSON): { path: number[]; node: TipTapJSON }[] {
const results: { path: number[]; node: TipTapJSON }[] = []
const walk = (node: TipTapJSON, path: number[]) => {
if (isGraphicNode(node)) {
results.push({ path, node })
}
if (Array.isArray(node.content)) {
node.content.forEach((child, index) => {
if (child && typeof child === "object") walk(child as TipTapJSON, [...path, index])
})
}
}
walk(content, [])
return results
}
function setNodeAtPath(root: TipTapJSON, path: number[], node: TipTapJSON): TipTapJSON {
if (path.length === 0) return node
const [head, ...rest] = path
const content = Array.isArray(root.content) ? [...root.content] : []
content[head!] = setNodeAtPath(content[head!] as TipTapJSON, rest, node)
return { ...root, content }
}
/** Enrich imported TipTap content with OOXML anchor/inline positioning. */
export async function enrichGraphicsFromDocxPositions(
buffer: ArrayBuffer,
content: TipTapJSON
): Promise<TipTapJSON> {
try {
const { unzipSync } = await import("fflate")
const archive = unzipSync(new Uint8Array(buffer)) as DocxArchive
const positions = extractGraphicPositionsFromDocx(archive)
if (positions.length === 0) return content
const graphics = collectGraphicNodes(content)
let result = content
const count = Math.min(graphics.length, positions.length)
for (let i = 0; i < count; i++) {
const { path, node } = graphics[i]!
const pos = positions[i]!
result = setNodeAtPath(result, path, applyPositionToNode(node, pos))
}
return result
} catch {
return content
}
}

View File

@ -6,20 +6,20 @@ export const DRIVE_DIALOG_OVERLAY =
"bg-[#3c4043]/40 backdrop-blur-[2px] dark:bg-[#202124]/60" "bg-[#3c4043]/40 backdrop-blur-[2px] dark:bg-[#202124]/60"
export const DRIVE_DIALOG_CONTENT = cn( export const DRIVE_DIALOG_CONTENT = cn(
"gap-0 overflow-hidden border-[#e8eaed] bg-white p-0 shadow-xl dark:border-[#5f6368]/30 dark:bg-[#292a2d]" "drive-dialog gap-0 overflow-hidden border-[#e8eaed] bg-white p-0 shadow-xl dark:border-[#3c4043] dark:bg-[#292a2d]"
) )
export const DRIVE_DIALOG_HEADER = cn( export const DRIVE_DIALOG_HEADER = cn(
"space-y-1 border-b border-[#e8eaed] px-6 py-5 text-left dark:border-[#5f6368]/30" "space-y-1 border-b border-[#e8eaed] px-6 py-5 text-left dark:border-[#3c4043]"
) )
export const DRIVE_DIALOG_BODY = "px-6 py-5" export const DRIVE_DIALOG_BODY = "px-6 py-5"
export const DRIVE_DIALOG_FOOTER = cn( export const DRIVE_DIALOG_FOOTER = cn(
"flex-row justify-end gap-2 border-t border-[#e8eaed] bg-[#f8f9fa] px-6 py-4 dark:border-[#5f6368]/30 dark:bg-[#35363a]/50" "flex-row justify-end gap-2 border-t border-[#e8eaed] bg-[#f8f9fa] px-6 py-4 dark:border-[#3c4043] dark:bg-[#252628]"
) )
export const DRIVE_DIALOG_DIVIDER = "border-[#e8eaed] dark:border-[#5f6368]/30" export const DRIVE_DIALOG_DIVIDER = "border-[#e8eaed] dark:border-[#3c4043]"
export const DRIVE_FIELD_CLASS = export const DRIVE_FIELD_CLASS =
"h-10 rounded-lg border border-[#dadce0] bg-[#f1f3f4] text-sm text-[#3c4043] shadow-none placeholder:text-[#80868b] focus-visible:border-[#1a73e8] focus-visible:ring-2 focus-visible:ring-[#1a73e8]/20 dark:border-[#5f6368]/40 dark:bg-[#35363a] dark:text-[#e8eaed] dark:placeholder:text-[#9aa0a6] dark:focus-visible:border-[#8ab4f8] dark:focus-visible:ring-[#8ab4f8]/25" "h-10 rounded-lg border border-[#dadce0] bg-[#f1f3f4] text-sm text-[#3c4043] shadow-none placeholder:text-[#80868b] focus-visible:border-[#1a73e8] focus-visible:ring-2 focus-visible:ring-[#1a73e8]/20 dark:border-[#5f6368]/40 dark:bg-[#35363a] dark:text-[#e8eaed] dark:placeholder:text-[#9aa0a6] dark:focus-visible:border-[#8ab4f8] dark:focus-visible:ring-[#8ab4f8]/25"
@ -34,7 +34,7 @@ export const DRIVE_TEXT_SECONDARY = "text-[#5f6368] dark:text-[#9aa0a6]"
export const DRIVE_TEXT_TITLE = "text-[#202124] dark:text-[#e8eaed]" export const DRIVE_TEXT_TITLE = "text-[#202124] dark:text-[#e8eaed]"
export const DRIVE_PANEL_MUTED = cn( export const DRIVE_PANEL_MUTED = cn(
"rounded-xl border border-[#e8eaed] bg-[#f8f9fa] dark:border-[#5f6368]/30 dark:bg-[#35363a]/70" "rounded-xl border border-[#e8eaed] bg-[#f8f9fa] dark:border-[#3c4043] dark:bg-[#35363a]"
) )
export const DRIVE_CARD_IDLE = cn( export const DRIVE_CARD_IDLE = cn(
@ -56,5 +56,5 @@ export const DRIVE_BTN_PRIMARY =
export const DRIVE_SHEET_OVERLAY = "z-[100] bg-[#3c4043]/40 backdrop-blur-[2px] dark:bg-[#202124]/60" export const DRIVE_SHEET_OVERLAY = "z-[100] bg-[#3c4043]/40 backdrop-blur-[2px] dark:bg-[#202124]/60"
export const DRIVE_SHEET_CONTENT = cn( export const DRIVE_SHEET_CONTENT = cn(
"z-[100] rounded-t-2xl border-t border-[#e8eaed] bg-white p-0 pb-[env(safe-area-inset-bottom)] dark:border-[#5f6368]/30 dark:bg-[#292a2d]" "drive-dialog z-[100] rounded-t-2xl border-t border-[#e8eaed] bg-white p-0 pb-[env(safe-area-inset-bottom)] dark:border-[#3c4043] dark:bg-[#292a2d]"
) )

View File

@ -0,0 +1,71 @@
"use client"
import { Extension } from "@tiptap/core"
import { Plugin, PluginKey } from "@tiptap/pm/state"
import { buildInsertGraphicAttrs } from "@/lib/drive/extensions/docs-graphic"
function readImageFile(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
export const DocsGraphicPasteDrop = Extension.create({
name: "docsGraphicPasteDrop",
addProseMirrorPlugins() {
const editor = this.editor
return [
new Plugin({
key: new PluginKey("docsGraphicPasteDrop"),
props: {
handlePaste(view, event) {
const items = event.clipboardData?.items
if (!items) return false
for (const item of items) {
if (!item.type.startsWith("image/")) continue
const file = item.getAsFile()
if (!file) continue
event.preventDefault()
void readImageFile(file).then((src) => {
editor
.chain()
.focus()
.insertDocsGraphic(
buildInsertGraphicAttrs("image", { src, width: 280, height: 180 })
)
.run()
})
return true
}
return false
},
handleDrop(view, event) {
const files = event.dataTransfer?.files
if (!files?.length) return false
const file = [...files].find((f) => f.type.startsWith("image/"))
if (!file) return false
event.preventDefault()
void readImageFile(file).then((src) => {
const coords = view.posAtCoords({
left: event.clientX,
top: event.clientY,
})
const chain = editor.chain().focus()
if (coords?.pos != null) chain.setTextSelection(coords.pos)
chain
.insertDocsGraphic(
buildInsertGraphicAttrs("image", { src, width: 280, height: 180 })
)
.run()
})
return true
},
},
}),
]
},
})

View File

@ -0,0 +1,240 @@
import { mergeAttributes, Node } from "@tiptap/core"
import { ReactNodeViewRenderer } from "@tiptap/react"
import { DocsGraphicNodeView } from "@/components/drive/richtext/docs-graphic-node-view"
import {
buildGradientCss,
DOCS_GRAPHIC_DEFAULTS,
type DocsGraphicAttrs,
type DocsGraphicFloatSide,
type DocsGraphicPlacement,
type DocsGraphicType,
type DocsGraphicWrap,
type DocsShapeType,
parseGraphicAttrs,
} from "@/lib/drive/docs-graphic-types"
declare module "@tiptap/core" {
interface Commands<ReturnType> {
docsGraphic: {
insertDocsGraphic: (attrs: Partial<DocsGraphicAttrs>) => ReturnType
updateDocsGraphic: (attrs: Partial<DocsGraphicAttrs>) => ReturnType
setDocsGraphicWrap: (wrap: DocsGraphicWrap) => ReturnType
setDocsGraphicPlacement: (placement: DocsGraphicPlacement) => ReturnType
setDocsGraphicFloatSide: (floatSide: DocsGraphicFloatSide) => ReturnType
bringDocsGraphicForward: () => ReturnType
sendDocsGraphicBackward: () => ReturnType
}
}
}
const graphicAttributes = {
graphicType: { default: DOCS_GRAPHIC_DEFAULTS.graphicType },
src: { default: null as string | null },
alt: { default: "" },
shapeType: { default: DOCS_GRAPHIC_DEFAULTS.shapeType },
fill: { default: DOCS_GRAPHIC_DEFAULTS.fill },
stroke: { default: DOCS_GRAPHIC_DEFAULTS.stroke },
strokeWidth: { default: DOCS_GRAPHIC_DEFAULTS.strokeWidth },
gradientCss: { default: "" },
gradientAngle: { default: DOCS_GRAPHIC_DEFAULTS.gradientAngle },
gradientColor1: { default: DOCS_GRAPHIC_DEFAULTS.gradientColor1 },
gradientColor2: { default: DOCS_GRAPHIC_DEFAULTS.gradientColor2 },
width: { default: DOCS_GRAPHIC_DEFAULTS.width },
height: { default: DOCS_GRAPHIC_DEFAULTS.height },
placement: { default: DOCS_GRAPHIC_DEFAULTS.placement },
wrap: { default: DOCS_GRAPHIC_DEFAULTS.wrap },
floatSide: { default: DOCS_GRAPHIC_DEFAULTS.floatSide },
x: { default: 0 },
y: { default: 0 },
rotationDeg: { default: 0 },
zIndex: { default: 0 },
cropX: { default: 0 },
cropY: { default: 0 },
cropWidth: { default: 1 },
cropHeight: { default: 1 },
cropShape: { default: "rect" },
assetId: { default: null as string | null },
opacity: { default: 1 },
shadow: { default: "" },
}
function mergeGraphicAttrs(partial: Partial<DocsGraphicAttrs>): DocsGraphicAttrs {
const merged = parseGraphicAttrs({ ...DOCS_GRAPHIC_DEFAULTS, ...partial })
if (merged.graphicType === "gradient" && !partial.gradientCss) {
merged.gradientCss = buildGradientCss(
merged.gradientAngle,
merged.gradientColor1,
merged.gradientColor2
)
}
return merged
}
function graphicCommands() {
return {
insertDocsGraphic:
(partial: Partial<DocsGraphicAttrs>) =>
({ chain }: { chain: () => { insertContent: (content: unknown) => { run: () => boolean } } }) => {
const attrs = mergeGraphicAttrs(partial)
const type =
attrs.placement === "inline" || attrs.wrap === "inline"
? "docsInlineGraphic"
: "docsGraphic"
return chain()
.insertContent({ type, attrs })
.run()
},
updateDocsGraphic:
(partial: Partial<DocsGraphicAttrs>) =>
({
chain,
editor,
}: {
chain: () => {
updateAttributes: (name: string, attrs: Partial<DocsGraphicAttrs>) => { run: () => boolean }
}
editor: { isActive: (name: string) => boolean }
}) => {
const name = editor.isActive("docsInlineGraphic") ? "docsInlineGraphic" : "docsGraphic"
return chain().updateAttributes(name, partial).run()
},
setDocsGraphicWrap:
(wrap: DocsGraphicWrap) =>
({
chain,
editor,
}: {
chain: () => {
updateAttributes: (name: string, attrs: { wrap: DocsGraphicWrap }) => { run: () => boolean }
}
editor: { isActive: (name: string) => boolean }
}) => {
const name = editor.isActive("docsInlineGraphic") ? "docsInlineGraphic" : "docsGraphic"
return chain().updateAttributes(name, { wrap }).run()
},
setDocsGraphicPlacement:
(placement: DocsGraphicPlacement) =>
({
chain,
editor,
}: {
chain: () => {
updateAttributes: (name: string, attrs: { placement: DocsGraphicPlacement }) => {
run: () => boolean
}
}
editor: { isActive: (name: string) => boolean }
}) => {
const name = editor.isActive("docsInlineGraphic") ? "docsInlineGraphic" : "docsGraphic"
return chain().updateAttributes(name, { placement }).run()
},
setDocsGraphicFloatSide:
(floatSide: DocsGraphicFloatSide) =>
({
chain,
editor,
}: {
chain: () => {
updateAttributes: (name: string, attrs: { floatSide: DocsGraphicFloatSide }) => {
run: () => boolean
}
}
editor: { isActive: (name: string) => boolean }
}) => {
const name = editor.isActive("docsInlineGraphic") ? "docsInlineGraphic" : "docsGraphic"
return chain().updateAttributes(name, { floatSide }).run()
},
bringDocsGraphicForward:
() =>
({
chain,
editor,
}: {
chain: () => {
updateAttributes: (name: string, attrs: { zIndex: number }) => { run: () => boolean }
}
editor: { isActive: (name: string) => boolean; getAttributes: (name: string) => Record<string, unknown> }
}) => {
const name = editor.isActive("docsInlineGraphic") ? "docsInlineGraphic" : "docsGraphic"
const z = Number(editor.getAttributes(name).zIndex ?? 0)
return chain().updateAttributes(name, { zIndex: z + 1 }).run()
},
sendDocsGraphicBackward:
() =>
({
chain,
editor,
}: {
chain: () => {
updateAttributes: (name: string, attrs: { zIndex: number }) => { run: () => boolean }
}
editor: { isActive: (name: string) => boolean; getAttributes: (name: string) => Record<string, unknown> }
}) => {
const name = editor.isActive("docsInlineGraphic") ? "docsInlineGraphic" : "docsGraphic"
const z = Number(editor.getAttributes(name).zIndex ?? 0)
return chain().updateAttributes(name, { zIndex: Math.max(0, z - 1) }).run()
},
}
}
export const DocsGraphic = Node.create({
name: "docsGraphic",
group: "block",
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return graphicAttributes
},
parseHTML() {
return [{ tag: 'div[data-type="docs-graphic"]' }]
},
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes(HTMLAttributes, { "data-type": "docs-graphic" })]
},
addNodeView() {
return ReactNodeViewRenderer(DocsGraphicNodeView)
},
addCommands() {
return graphicCommands()
},
})
export const DocsInlineGraphic = Node.create({
name: "docsInlineGraphic",
group: "inline",
inline: true,
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return graphicAttributes
},
parseHTML() {
return [{ tag: 'span[data-type="docs-inline-graphic"]' }]
},
renderHTML({ HTMLAttributes }) {
return ["span", mergeAttributes(HTMLAttributes, { "data-type": "docs-inline-graphic" })]
},
addNodeView() {
return ReactNodeViewRenderer(DocsGraphicNodeView)
},
})
export function buildInsertGraphicAttrs(
graphicType: DocsGraphicType,
partial: Partial<DocsGraphicAttrs> = {}
): DocsGraphicAttrs {
return mergeGraphicAttrs({ ...DOCS_GRAPHIC_DEFAULTS, graphicType, ...partial })
}
export { mergeGraphicAttrs }

View File

@ -0,0 +1,209 @@
import { Extension } from "@tiptap/core"
import type { Editor } from "@tiptap/react"
import { Plugin, PluginKey } from "@tiptap/pm/state"
import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"
export const PAGE_FLOW_PLUGIN_KEY = new PluginKey<DecorationSet>("docsPageFlowDecoration")
function decorationSetsEqual(
current: DecorationSet | undefined,
next: DecorationSet
): boolean {
if (!current || current === DecorationSet.empty) {
return next === DecorationSet.empty || next.find(0, Number.MAX_SAFE_INTEGER).length === 0
}
const a = current.find(0, Number.MAX_SAFE_INTEGER)
const b = next.find(0, Number.MAX_SAFE_INTEGER)
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i].from !== b[i].from) return false
const aPush = (a[i].spec as { pushPx?: number }).pushPx
const bPush = (b[i].spec as { pushPx?: number }).pushPx
if (aPush !== bPush) return false
}
return true
}
export function readPageFlowMetrics(prose: HTMLElement): {
bodyAreaH: number
interPageSpacer: number
} | null {
const surface = prose.closest(".ultidrive-docs-editor-surface")
if (!surface) return null
const styles = getComputedStyle(surface)
const bodyAreaH = parseFloat(styles.getPropertyValue("--docs-body-area-h"))
const interPageSpacer = parseFloat(styles.getPropertyValue("--docs-inter-page-spacer"))
if (!Number.isFinite(bodyAreaH) || bodyAreaH <= 0) return null
if (!Number.isFinite(interPageSpacer) || interPageSpacer <= 0) return null
return { bodyAreaH, interPageSpacer }
}
function blockDomAtOffset(view: EditorView, offset: number): HTMLElement | null {
const dom = view.nodeDOM(offset)
if (dom instanceof HTMLElement) {
if (dom.parentElement === view.dom) return dom
let el: HTMLElement | null = dom
while (el && el.parentElement !== view.dom) el = el.parentElement
return el
}
return null
}
/** Block height in prose px (offsetHeight + bottom margin; immune to canvas scale). */
function measureBlockFlowHeight(dom: HTMLElement): number {
const style = getComputedStyle(dom)
const marginBottom = parseFloat(style.marginBottom) || 0
return dom.offsetHeight + marginBottom
}
function createSpacerElement(height: number): HTMLElement {
const el = document.createElement("div")
el.className = "docs-page-flow-spacer"
el.style.cssText = `display:block;width:100%;height:${height}px;margin:0;padding:0;border:0;flex-shrink:0`
el.setAttribute("aria-hidden", "true")
el.contentEditable = "false"
return el
}
/** Simulate vertical flow without reading pushed DOM positions (avoids clearing decorations). */
export function computePageFlowPushes(
blocks: Array<{ height: number }>,
bodyAreaH: number,
interPageSpacer: number
): Array<{ blockIndex: number; pushPx: number; breakY: number }> {
const pageStep = bodyAreaH + interPageSpacer
const pushes: Array<{ blockIndex: number; pushPx: number; breakY: number }> = []
let simulatedY = 0
let nextBreakY = bodyAreaH
blocks.forEach(({ height: blockH }, blockIndex) => {
if (blockH <= 0) return
while (simulatedY + blockH > nextBreakY) {
if (simulatedY < nextBreakY) {
const pushPx = nextBreakY - simulatedY + interPageSpacer
pushes.push({ blockIndex, pushPx, breakY: nextBreakY })
simulatedY = nextBreakY + interPageSpacer
nextBreakY += pageStep
break
}
nextBreakY += pageStep
}
simulatedY += blockH
})
return pushes
}
export function computeSimulatedLayoutHeight(
blocks: Array<{ height: number }>,
bodyAreaH: number,
interPageSpacer: number
): number {
const pageStep = bodyAreaH + interPageSpacer
let simulatedY = 0
let nextBreakY = bodyAreaH
for (const { height: blockH } of blocks) {
if (blockH <= 0) continue
while (simulatedY + blockH > nextBreakY) {
if (simulatedY < nextBreakY) {
simulatedY = nextBreakY + interPageSpacer
nextBreakY += pageStep
break
}
nextBreakY += pageStep
}
simulatedY += blockH
}
return simulatedY
}
function collectFlowBlocks(view: EditorView): Array<{ height: number; offset: number }> {
const blocks: Array<{ height: number; offset: number }> = []
view.state.doc.forEach((node, offset) => {
if (!node.isBlock || node.type.name === "doc") return
const dom = blockDomAtOffset(view, offset)
if (!dom) return
const blockH = measureBlockFlowHeight(dom)
if (blockH <= 0) return
blocks.push({ height: blockH, offset })
})
return blocks
}
/** Build page-flow spacer widgets for blocks that cross a page body boundary. */
export function buildPageFlowDecorations(view: EditorView): DecorationSet {
const metrics = readPageFlowMetrics(view.dom)
if (!metrics) return DecorationSet.empty
const { bodyAreaH, interPageSpacer } = metrics
const blocks = collectFlowBlocks(view)
const pushes = computePageFlowPushes(
blocks.map((b) => ({ height: b.height })),
bodyAreaH,
interPageSpacer
)
const decorations = pushes.map(({ blockIndex, pushPx, breakY }) => {
const block = blocks[blockIndex]
return Decoration.widget(
block.offset,
() => createSpacerElement(pushPx),
{ side: -1, key: `page-flow-spacer-${block.offset}-${breakY}`, pushPx }
)
})
return DecorationSet.create(view.state.doc, decorations)
}
/** Apply page-flow layout once. Returns true if decorations changed. */
export function applyPageFlowLayout(editor: Editor): boolean {
if (editor.isDestroyed || !editor.isInitialized) return false
const view = editor.view
const next = buildPageFlowDecorations(view)
const current = PAGE_FLOW_PLUGIN_KEY.getState(view.state)
if (decorationSetsEqual(current, next)) return false
const tr = view.state.tr.setMeta(PAGE_FLOW_PLUGIN_KEY, next)
tr.setMeta("addToHistory", false)
view.dispatch(tr)
return true
}
/** Pure helper for tests: return count of inter-page pushes. */
export function countPageFlowSpacers(
blocks: Array<{ height: number }>,
bodyAreaH: number,
interPageSpacer: number
): number {
return computePageFlowPushes(blocks, bodyAreaH, interPageSpacer).length
}
/** State-only plugin: layout is driven from docs-page-view (no view plugin / observers). */
export const DocsPageFlowDecoration = Extension.create({
name: "docsPageFlowDecoration",
addProseMirrorPlugins() {
return [
new Plugin({
key: PAGE_FLOW_PLUGIN_KEY,
state: {
init: () => DecorationSet.empty,
apply(tr, set) {
const next = tr.getMeta(PAGE_FLOW_PLUGIN_KEY)
if (next instanceof DecorationSet) return next
return set.map(tr.mapping, tr.doc)
},
},
props: {
decorations(state) {
return PAGE_FLOW_PLUGIN_KEY.getState(state) ?? DecorationSet.empty
},
},
}),
]
},
})

View File

@ -1,4 +1,5 @@
import type { Editor } from "@tiptap/react" import type { Editor } from "@tiptap/react"
import { readPageFlowMetrics } from "@/lib/drive/extensions/docs-page-flow-decoration"
/** Focus editor at viewport coords; clamp to prose bounds when click is on padding. */ /** Focus editor at viewport coords; clamp to prose bounds when click is on padding. */
export function focusEditorAtPointer( export function focusEditorAtPointer(
@ -14,7 +15,8 @@ export function focusEditorAtPointer(
return return
} }
const rect = editor.view.dom.getBoundingClientRect() const prose = editor.view.dom
const rect = prose.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) { if (rect.width <= 0 || rect.height <= 0) {
editor.chain().focus("end").run() editor.chain().focus("end").run()
return return
@ -28,5 +30,14 @@ export function focusEditorAtPointer(
return return
} }
const metrics = readPageFlowMetrics(prose)
if (metrics) {
const localY = clientY - rect.top
if (localY >= metrics.bodyAreaH) {
editor.chain().focus("end").run()
return
}
}
editor.chain().focus(clientY < rect.top + rect.height / 2 ? "start" : "end").run() editor.chain().focus(clientY < rect.top + rect.height / 2 ? "start" : "end").run()
} }

View File

@ -16,6 +16,9 @@ import CollaborationCaret from "@tiptap/extension-collaboration-caret"
import type { HocuspocusProvider } from "@hocuspocus/provider" import type { HocuspocusProvider } from "@hocuspocus/provider"
import type * as Y from "yjs" import type * as Y from "yjs"
import { DocsEditorShortcuts } from "@/lib/drive/docs-editor-shortcuts" import { DocsEditorShortcuts } from "@/lib/drive/docs-editor-shortcuts"
import { DocsGraphic, DocsInlineGraphic } from "@/lib/drive/extensions/docs-graphic"
import { DocsGraphicPasteDrop } from "@/lib/drive/extensions/docs-graphic-paste-drop"
import { DocsPageFlowDecoration } from "@/lib/drive/extensions/docs-page-flow-decoration"
export function buildRichTextExtensions(options?: { export function buildRichTextExtensions(options?: {
collaboration?: { document: Y.Doc } collaboration?: { document: Y.Doc }
@ -61,6 +64,10 @@ export function buildRichTextExtensions(options?: {
TableRow, TableRow,
TableCell, TableCell,
TableHeader, TableHeader,
DocsGraphic,
DocsInlineGraphic,
DocsGraphicPasteDrop,
DocsPageFlowDecoration,
Image.configure({ inline: true, allowBase64: true }), Image.configure({ inline: true, allowBase64: true }),
Placeholder.configure({ Placeholder.configure({
placeholder: options?.placeholder ?? "Commencez à écrire…", placeholder: options?.placeholder ?? "Commencez à écrire…",
@ -73,3 +80,36 @@ export function buildRichTextExtensions(options?: {
export const RICHTEXT_EDITOR_CLASS = export const RICHTEXT_EDITOR_CLASS =
"ultidrive-richtext-editor max-w-none outline-none focus:outline-none prose prose-sm" "ultidrive-richtext-editor max-w-none outline-none focus:outline-none prose prose-sm"
export const RICHTEXT_REGION_EDITOR_CLASS =
"ultidrive-richtext-region-editor max-w-none outline-none focus:outline-none prose prose-sm"
/** Full-feature extensions for inline header/footer editors (no collab, no page flow). */
export function buildRegionEditorExtensions(placeholder?: string): Extensions {
return [
StarterKit.configure({ heading: { levels: [1, 2, 3, 4] } }),
Underline,
Link.configure({ openOnClick: false }),
TextStyle,
FontFamily,
FontSize,
Color,
BackgroundColor,
Highlight.configure({ multicolor: true }),
TextAlign.configure({
types: ["heading", "paragraph"],
alignments: ["left", "center", "right", "justify"],
}),
Table.configure({ resizable: true }),
TableRow,
TableCell,
TableHeader,
DocsGraphic,
DocsInlineGraphic,
DocsGraphicPasteDrop,
Image.configure({ inline: true, allowBase64: true }),
Placeholder.configure({
placeholder: placeholder ?? "Saisissez un en-tête ou un pied de page",
}),
]
}

View File

@ -0,0 +1,111 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import {
normalizeImportedGraphics,
normalizeLegacyImageAttrs,
} from "./docs-graphic-import.ts"
import { extractGraphicPositionsFromDocx } from "./docx-position-import.ts"
import { computeCropImageStyle } from "./docs-graphic-types.ts"
function normalizeImportedTipTap(content: Record<string, unknown>) {
return normalizeImportedGraphics(normalizeLegacyImageAttrs(content))
}
describe("richtext-import chain", () => {
it("preserves placement and rotation through full chain", () => {
const result = normalizeImportedTipTap({
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "image",
attrs: {
src: "data:image/png;base64,abc",
width: 200,
height: 120,
placement: "absolute",
wrap: "square",
x: 40,
y: 20,
rotationDeg: 15,
floatSide: "right",
},
},
],
},
],
})
const node = result.content?.[0] as { type?: string; attrs?: Record<string, unknown> }
assert.equal(node.type, "docsGraphic")
assert.equal(node.attrs?.placement, "absolute")
assert.equal(node.attrs?.x, 40)
assert.equal(node.attrs?.y, 20)
assert.equal(node.attrs?.rotationDeg, 15)
assert.equal(node.attrs?.floatSide, "right")
})
it("preserves crop attrs through full chain", () => {
const result = normalizeImportedTipTap({
type: "doc",
content: [
{
type: "image",
attrs: {
src: "data:image/png;base64,abc",
cropX: 0.1,
cropY: 0.2,
cropWidth: 0.8,
cropHeight: 0.7,
},
},
],
})
const node = result.content?.[0] as { attrs?: Record<string, unknown> }
assert.equal(node.attrs?.cropX, 0.1)
assert.equal(node.attrs?.cropY, 0.2)
assert.equal(node.attrs?.cropWidth, 0.8)
assert.equal(node.attrs?.cropHeight, 0.7)
})
})
describe("docx-position-import", () => {
it("extractGraphicPositionsFromDocx parses inline extent", () => {
const archive = {
"word/document.xml": new TextEncoder().encode(`
<w:document>
<w:body>
<w:p>
<w:drawing>
<wp:inline>
<wp:extent cx="914400" cy="457200"/>
</wp:inline>
</w:drawing>
</w:p>
</w:body>
</w:document>
`),
}
const positions = extractGraphicPositionsFromDocx(archive)
assert.equal(positions.length, 1)
assert.equal(positions[0]?.width, 96)
assert.equal(positions[0]?.height, 48)
assert.equal(positions[0]?.placement, "inline")
assert.equal(positions[0]?.wrap, "inline")
})
})
describe("docs-graphic-types crop", () => {
it("computeCropImageStyle returns styles when crop active", () => {
const style = computeCropImageStyle({
cropX: 0.1,
cropY: 0,
cropWidth: 0.8,
cropHeight: 1,
cropShape: "rect",
})
assert.ok(style.img.width)
assert.equal(style.clipPath, undefined)
})
})

View File

@ -1,4 +1,11 @@
import mammoth from "mammoth" import mammoth from "mammoth"
import {
normalizeImportedGraphics,
normalizeLegacyImageAttrs,
} from "@/lib/drive/docs-graphic-import"
import { enrichContentFromDocxDrawings } from "@/lib/drive/docx-drawing-import"
import { enrichGraphicsFromDocxPositions } from "@/lib/drive/docx-position-import"
import { extractDocxHeaderFooter } from "@/lib/drive/docx-header-footer-import"
import { import {
extractDocxPageSetup, extractDocxPageSetup,
type DocPageSetup, type DocPageSetup,
@ -11,8 +18,6 @@ export type RichTextImportResult = {
pageSetup?: DocPageSetup | null pageSetup?: DocPageSetup | null
} }
const IMAGE_ATTR_KEYS = ["src", "alt", "title", "width", "height"] as const
function imageNodeFromElement(el: HTMLElement): TipTapJSON | null { function imageNodeFromElement(el: HTMLElement): TipTapJSON | null {
const src = el.getAttribute("src") const src = el.getAttribute("src")
if (!src) return null if (!src) return null
@ -50,35 +55,10 @@ function inlineContentFromElement(el: HTMLElement): TipTapJSON[] {
return content return content
} }
/** Keep only attrs supported by @tiptap/extension-image. */ /** Preserve graphic positioning attrs, then upgrade to docsGraphic nodes. */
export function normalizeImportedTipTap(content: TipTapJSON): TipTapJSON { export function normalizeImportedTipTap(content: TipTapJSON): TipTapJSON {
const walk = (node: unknown): unknown => { const withLegacy = normalizeLegacyImageAttrs(content)
if (!node || typeof node !== "object") return node return normalizeImportedGraphics(withLegacy)
if (Array.isArray(node)) return node.map(walk)
const record = node as TipTapJSON
if (record.type === "image" && record.attrs && typeof record.attrs === "object") {
const raw = record.attrs as Record<string, unknown>
const attrs: Record<string, unknown> = {}
for (const key of IMAGE_ATTR_KEYS) {
const value = raw[key]
if (value != null && value !== "") attrs[key] = value
}
if (typeof attrs.src !== "string" || !attrs.src) {
return null
}
return { ...record, attrs }
}
if (Array.isArray(record.content)) {
const nextContent = record.content.map(walk).filter(Boolean)
return { ...record, content: nextContent }
}
return record
}
const normalized = walk(content)
if (!normalized || typeof normalized !== "object") {
return { type: "doc", content: [{ type: "paragraph" }] }
}
return normalized as TipTapJSON
} }
function htmlToTipTapDoc(html: string): TipTapJSON { function htmlToTipTapDoc(html: string): TipTapJSON {
@ -148,11 +128,40 @@ function htmlToTipTapDoc(html: string): TipTapJSON {
export async function importDocxToTipTap(buffer: ArrayBuffer): Promise<RichTextImportResult> { export async function importDocxToTipTap(buffer: ArrayBuffer): Promise<RichTextImportResult> {
const pageSetup = await extractDocxPageSetup(buffer) const pageSetup = await extractDocxPageSetup(buffer)
const headerFooter = await extractDocxHeaderFooter(buffer)
const normalizeRegion = (region: typeof headerFooter.header) => {
if (!region?.content) return region
return { ...region, content: normalizeImportedTipTap(region.content as TipTapJSON) }
}
const mergedPageSetup: DocPageSetup | null = pageSetup
? {
...pageSetup,
header: normalizeRegion(headerFooter.header ?? pageSetup.header ?? null),
footer: normalizeRegion(headerFooter.footer ?? pageSetup.footer ?? null),
headerFooterDifferentFirstPage:
headerFooter.headerFooterDifferentFirstPage ??
pageSetup.headerFooterDifferentFirstPage ??
false,
}
: headerFooter.header || headerFooter.footer
? {
widthMm: 210,
heightMm: 297,
marginsMm: { top: 25.4, right: 25.4, bottom: 25.4, left: 25.4 },
header: normalizeRegion(headerFooter.header ?? null),
footer: normalizeRegion(headerFooter.footer ?? null),
headerFooterDifferentFirstPage: headerFooter.headerFooterDifferentFirstPage ?? false,
}
: null
try { try {
const { parseDOCX } = await import("@docen/import-docx") const { parseDOCX } = await import("@docen/import-docx")
const content = await parseDOCX(buffer, { image: { crop: false } }) const content = await parseDOCX(buffer, { image: { crop: true } })
if (content && typeof content === "object") { if (content && typeof content === "object") {
return { content: normalizeImportedTipTap(content as TipTapJSON), pageSetup } let normalized = normalizeImportedTipTap(content as TipTapJSON)
normalized = await enrichGraphicsFromDocxPositions(buffer, normalized)
normalized = await enrichContentFromDocxDrawings(buffer, normalized)
return { content: normalized, pageSetup: mergedPageSetup }
} }
} catch (error) { } catch (error) {
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
@ -169,7 +178,10 @@ export async function importDocxToTipTap(buffer: ArrayBuffer): Promise<RichTextI
), ),
} }
) )
return { content: htmlToTipTapDoc(result.value), pageSetup } let content = htmlToTipTapDoc(result.value)
content = await enrichGraphicsFromDocxPositions(buffer, content)
content = await enrichContentFromDocxDrawings(buffer, content)
return { content, pageSetup: mergedPageSetup }
} }
export async function exportTipTapToDocx(content: TipTapJSON): Promise<Blob> { export async function exportTipTapToDocx(content: TipTapJSON): Promise<Blob> {

View File

@ -0,0 +1,185 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import type { Editor } from "@tiptap/react"
import type { DocPageLayout } from "@/lib/drive/doc-page-setup"
import { DOCS_PAGE_GAP_PX } from "@/lib/drive/docs-page-layout-constants"
import {
docsPageLengthToScreen,
docsScreenLengthToPage,
docsZoomToScale,
} from "@/lib/drive/docs-ruler-scale"
export type DocsParagraphIndents = {
leftPx: number
firstLinePx: number
rightPx: number
}
export type DocsRulerSyncState = {
currentPage: number
pageTopInViewport: number
pageHeightScaled: number
/** Canvas client width — ruler track content area matches this. */
canvasWidth: number
/** Scrollbar gutter so ruler track centers like the canvas viewport. */
canvasScrollbarWidth: number
canvasScrollLeft: number
indents: DocsParagraphIndents
}
const DEFAULT_INDENTS = (layout: DocPageLayout): DocsParagraphIndents => ({
leftPx: layout.marginsPx.left,
firstLinePx: layout.marginsPx.left,
rightPx: layout.widthPx - layout.marginsPx.right,
})
function readIndents(
editor: Editor | null,
scale: number,
layout: DocPageLayout
): DocsParagraphIndents {
const fallback = DEFAULT_INDENTS(layout)
if (!editor || editor.isDestroyed) return fallback
const prose = editor.view.dom as HTMLElement | null
if (!prose) return fallback
const { from } = editor.state.selection
const domPos = editor.view.domAtPos(from)
let el = domPos.node as HTMLElement
if (el.nodeType === Node.TEXT_NODE) el = el.parentElement ?? el
const block = el.closest?.(
"p, h1, h2, h3, h4, li, blockquote, pre, td, th"
) as HTMLElement | null
if (!block || !prose.contains(block)) return fallback
const pageStack = prose.closest(
"[data-docs-page-stack]"
) as HTMLElement | null
if (!pageStack) return fallback
const pageRect = pageStack.getBoundingClientRect()
const blockRect = block.getBoundingClientRect()
const leftPx = docsScreenLengthToPage(blockRect.left - pageRect.left, scale)
const style = getComputedStyle(block)
const textIndent = parseFloat(style.textIndent) || 0
return {
leftPx: Math.max(layout.marginsPx.left, leftPx),
firstLinePx: Math.max(layout.marginsPx.left, leftPx + textIndent),
rightPx: layout.widthPx - layout.marginsPx.right,
}
}
import {
computePageTopInViewport,
resolveCurrentPageInViewport,
} from "./docs-ruler-sync-math"
export function useDocsRulerSync({
canvasRef,
rulerTrackRef,
editor,
pageLayout,
zoom,
pageCount,
}: {
canvasRef: React.RefObject<HTMLDivElement | null>
rulerTrackRef: React.RefObject<HTMLDivElement | null>
editor: Editor | null
pageLayout: DocPageLayout
zoom: number
pageCount: number
narrowViewport: boolean
}) {
const scale = docsZoomToScale(zoom)
const pageHeight = pageLayout.heightPx
const pageHeightScaled = docsPageLengthToScreen(pageHeight, scale)
const gapScaled = docsPageLengthToScreen(DOCS_PAGE_GAP_PX, scale)
const pageStride = pageHeightScaled + gapScaled
const [state, setState] = useState<DocsRulerSyncState>(() => ({
currentPage: 0,
pageTopInViewport: 0,
pageHeightScaled,
canvasWidth: 0,
canvasScrollbarWidth: 0,
canvasScrollLeft: 0,
indents: DEFAULT_INDENTS(pageLayout),
}))
const sync = useCallback(() => {
const canvas = canvasRef.current
if (!canvas) return
const canvasRect = canvas.getBoundingClientRect()
const stack = canvas.querySelector("[data-docs-page-stack]") as HTMLElement | null
let pageTopInViewport = 0
let currentPage = 0
if (stack) {
const stackRect = stack.getBoundingClientRect()
const stackTopInViewport = stackRect.top - canvasRect.top
currentPage = resolveCurrentPageInViewport(
stackTopInViewport,
canvas.clientHeight,
pageStride,
pageCount
)
pageTopInViewport = computePageTopInViewport(
stackTopInViewport,
currentPage,
pageStride
)
}
setState({
currentPage,
pageTopInViewport,
pageHeightScaled,
canvasWidth: canvas.clientWidth,
canvasScrollbarWidth: Math.max(0, canvas.offsetWidth - canvas.clientWidth),
canvasScrollLeft: canvas.scrollLeft,
indents: readIndents(editor, scale, pageLayout),
})
}, [canvasRef, editor, pageCount, pageHeightScaled, pageLayout, pageStride, scale])
useEffect(() => {
sync()
const canvas = canvasRef.current
if (!canvas) return
canvas.addEventListener("scroll", sync, { passive: true })
const ro = new ResizeObserver(sync)
ro.observe(canvas)
const rulerTrack = rulerTrackRef.current
if (rulerTrack) ro.observe(rulerTrack)
return () => {
canvas.removeEventListener("scroll", sync)
ro.disconnect()
}
}, [canvasRef, rulerTrackRef, sync])
useEffect(() => {
if (!editor || editor.isDestroyed) return
const onSelection = () => sync()
editor.on("selectionUpdate", onSelection)
editor.on("transaction", onSelection)
return () => {
editor.off("selectionUpdate", onSelection)
editor.off("transaction", onSelection)
}
}, [editor, sync])
useEffect(() => {
sync()
}, [pageLayout, zoom, pageCount, sync])
return state
}

View File

@ -36,6 +36,290 @@
height: auto; height: auto;
} }
.ultidrive-richtext-editor .ProseMirror {
position: relative;
}
.ultidrive-richtext-editor .docs-graphic-host {
line-height: 0;
}
.ultidrive-richtext-editor .docs-graphic-host--inline {
display: inline;
}
.ultidrive-richtext-editor .docs-graphic {
position: relative;
box-sizing: border-box;
user-select: none;
}
.ultidrive-richtext-editor .docs-graphic--behind {
z-index: 0;
}
.ultidrive-richtext-editor .docs-graphic--front {
z-index: 20;
}
.ultidrive-richtext-editor .docs-graphic__content {
border-radius: 2px;
overflow: hidden;
}
.ultidrive-richtext-editor .docs-graphic-outline {
position: absolute;
inset: 0;
border: 2px solid #1a73e8;
border-radius: 2px;
pointer-events: none;
}
.ultidrive-richtext-editor .docs-graphic-handle {
touch-action: none;
}
.ultidrive-richtext-editor .docs-graphic--selected .docs-graphic__content {
outline: none;
}
.ultidrive-richtext-editor .ProseMirror-selectednode .docs-graphic-outline {
border-color: #1a73e8;
}
.ultidrive-richtext-editor .docs-graphic--interacting {
user-select: none;
}
.ultidrive-richtext-editor .docs-graphic--cropping {
cursor: crosshair;
}
.ultidrive-richtext-editor .docs-graphic-rotate-handle {
touch-action: none;
}
.ultidrive-richtext-editor .docs-page-region {
user-select: none;
}
.docs-hf-band {
color: #3c4043;
z-index: 25;
}
.docs-hf-band--editing {
z-index: 26 !important;
background-color: var(--docs-hf-page-color, #fff);
}
.docs-hf-hit-area {
z-index: 25;
}
.docs-hf-editing-backdrop {
z-index: 24;
}
.docs-hf-separator {
z-index: 24;
}
.docs-hf-chrome {
z-index: 27;
}
.docs-hf-band--editing.docs-hf-band--header .docs-region-editor-root {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.docs-hf-band--editing.docs-hf-band--footer .docs-region-editor-root {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.docs-hf-band--header {
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.docs-hf-band--footer {
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.docs-hf-band--editing .docs-region-editor-root,
.docs-hf-band--editing .docs-region-editor,
.docs-hf-band--editing .ultidrive-richtext-region-editor {
height: auto !important;
min-height: 0 !important;
max-height: none !important;
background: transparent;
}
.docs-hf-band--editing .ultidrive-richtext-region-editor .ProseMirror {
height: auto !important;
max-height: none !important;
background: transparent;
}
.docs-hf-band .docs-region-editor-root,
.docs-hf-band .docs-region-editor-root--grow,
.docs-hf-band .docs-region-editor--grow {
height: auto;
margin: 0;
padding: 0;
}
.docs-hf-band .docs-region-editor-root .ultidrive-richtext-region-editor,
.docs-hf-band .docs-region-editor-root .ultidrive-richtext-region-editor .ProseMirror {
height: auto;
max-height: none;
overflow: visible;
}
.docs-hf-band:not(.docs-hf-band--editing) .docs-region-editor-root .ultidrive-richtext-region-editor .ProseMirror {
overflow: hidden;
}
.docs-hf-band .ultidrive-richtext-region-editor {
display: block;
margin: 0;
padding: 0;
}
.docs-hf-band .ultidrive-richtext-region-editor .ProseMirror {
min-height: 0;
padding: 0;
margin: 0;
font-size: inherit;
line-height: 1.5;
}
.docs-hf-band .ultidrive-richtext-region-editor.prose :where(p, h1, h2, h3, h4):first-child {
margin-top: 0;
}
.docs-hf-band .ultidrive-richtext-region-editor.prose :where(p, h1, h2, h3, h4):last-child {
margin-bottom: 0;
}
.docs-hf-band--footer .docs-region-editor-root {
flex-shrink: 0;
}
.docs-hf-chrome__bar {
box-shadow: none;
}
.docs-hf-chrome__label {
font-size: 13px;
font-weight: 500;
color: #3c4043;
line-height: 1.2;
}
.docs-hf-chrome__checkbox-label {
font-size: 13px;
color: #3c4043;
line-height: 1.2;
user-select: none;
}
.ultidrive-docs-editor-surface {
box-sizing: border-box;
position: relative;
z-index: 16;
transition: padding 60ms ease-out;
}
.ultidrive-docs-editor-surface--paginated {
overflow: hidden;
}
.ultidrive-docs-editor-surface--paginated .ProseMirror {
min-height: var(--docs-prose-min-height);
max-height: var(--docs-prose-min-height);
overflow: hidden;
}
.docs-hf-chrome__checkbox {
width: 14px;
height: 14px;
min-width: 14px;
min-height: 14px;
border-radius: 2px;
border: 2px solid #5f6368;
background: #fff;
box-shadow: none;
}
.docs-hf-chrome__checkbox[data-state="checked"] {
background: #1a73e8;
border-color: #1a73e8;
color: #fff;
}
.docs-hf-chrome__checkbox[data-state="checked"] svg {
width: 10px;
height: 10px;
stroke-width: 3;
}
.docs-hf-chrome__options {
font-size: 13px;
font-weight: 500;
color: #1a73e8;
}
.docs-hf-chrome__options:hover {
color: #174ea6;
background-color: rgba(26, 115, 232, 0.08) !important;
}
html.dark .docs-hf-chrome__options {
color: #8ab4f8;
}
html.dark .docs-hf-chrome__options:hover {
color: #aecbfa;
background-color: rgba(138, 180, 248, 0.16) !important;
}
.docs-hf-band .ultidrive-richtext-region-editor .ProseMirror p {
margin: 0;
}
.ultidrive-docs-editor-surface--dimmed {
pointer-events: auto;
}
.ultidrive-docs-editor-surface--dimmed .ProseMirror {
opacity: 0.45;
pointer-events: none;
}
.docs-page-flow-spacer {
width: 100%;
flex-shrink: 0;
pointer-events: none;
user-select: none;
}
.ultidrive-docs-editor-surface .ProseMirror .docs-page-flow-spacer {
display: block;
margin: 0;
padding: 0;
border: 0;
}
.ultidrive-richtext-editor td, .ultidrive-richtext-editor td,
.ultidrive-richtext-editor th { .ultidrive-richtext-editor th {
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
@ -161,10 +445,59 @@ html.dark [data-docs-menu-surface] [data-slot="menubar-separator"] {
gap: 10px; gap: 10px;
} }
.docs-toolbar-ruler-row {
z-index: 15;
overflow: visible;
}
.docs-rulers-left-rail {
z-index: 15;
}
.docs-ruler-drag-handle:hover svg path,
.docs-ruler-drag-handle:active svg path {
fill: #174ea6;
}
html.dark .docs-ruler-drag-handle:hover svg path,
html.dark .docs-ruler-drag-handle:active svg path {
fill: #8ab4f8;
}
.docs-ruler-margin-tooltip {
background-color: #3c4043;
border: 1px solid #5f6368;
white-space: nowrap;
}
html.dark .docs-ruler-margin-tooltip {
background-color: #e8eaed;
color: #202124;
border-color: #9aa0a6;
}
.docs-toolbar-shell .docs-toolbar + .docs-toolbar-ruler-row {
margin-top: 0;
}
.docs-editor-workspace .ultidrive-docs-canvas {
background-color: #f9fbfd;
height: 100%;
min-height: 0;
}
html.dark .docs-editor-workspace .ultidrive-docs-canvas {
background-color: #202124;
}
.docs-menu-sub-content { .docs-menu-sub-content {
z-index: 60; z-index: 60;
} }
[data-slot="menubar-sub-content"][data-state="closed"] {
pointer-events: none;
}
.docs-menu-item-icon { .docs-menu-item-icon {
display: inline-flex; display: inline-flex;
width: 20px; width: 20px;
@ -178,14 +511,67 @@ html.dark .docs-menu-item-icon {
color: #e8eaed; color: #e8eaed;
} }
.docs-menu-mode-item {
min-height: 52px;
}
.docs-menu-checkbox-item {
padding-left: 2rem;
}
.docs-menu-shortcut-sequence {
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
white-space: nowrap;
}
.ultidrive-docs-editor-surface--compact {
background: #fff;
box-shadow: none;
}
.docs-show-non-printable .ultidrive-richtext-editor p::after,
.docs-show-non-printable .ProseMirror p::after {
content: "¶";
color: #bdc1c6;
font-size: 10px;
margin-left: 2px;
pointer-events: none;
user-select: none;
}
.docs-show-non-printable .ultidrive-richtext-editor p:empty::before,
.docs-show-non-printable .ProseMirror p:empty::before {
content: "¶";
color: #bdc1c6;
font-size: 10px;
pointer-events: none;
user-select: none;
}
.docs-editor-mode-suggest .ultidrive-richtext-editor,
.docs-editor-mode-suggest .ProseMirror {
caret-color: #1967d2;
}
.docs-editor-mode-view .ultidrive-richtext-editor,
.docs-editor-mode-view .ProseMirror {
cursor: default;
}
.docs-toolbar-shell { .docs-toolbar-shell {
padding: 0 12px 8px; padding: 0;
} }
.docs-toolbar-shell--collapsed { .docs-toolbar-shell--collapsed {
padding-top: 8px; padding-top: 8px;
} }
.docs-toolbar-shell > .docs-toolbar {
padding-inline: 12px;
margin-bottom: 8px;
}
.docs-toolbar { .docs-toolbar {
flex-wrap: nowrap; flex-wrap: nowrap;
color: #202124; color: #202124;
@ -414,6 +800,7 @@ html.dark .docs-menu-item-icon {
} }
.ultidrive-docs-editor-surface .ProseMirror { .ultidrive-docs-editor-surface .ProseMirror {
display: flow-root;
min-height: var(--docs-prose-min-height, 600px); min-height: var(--docs-prose-min-height, 600px);
} }
@ -471,6 +858,7 @@ html.dark .docs-menu-item-icon {
} }
.ultidrive-richtext-editor .ProseMirror { .ultidrive-richtext-editor .ProseMirror {
position: relative;
color: #000000; color: #000000;
caret-color: #000000; caret-color: #000000;
} }

File diff suppressed because one or more lines are too long