ultisuite-client/components/drive/richtext-document.tsx
R3D347HR4Y bd9605c853
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat(drive): update document printing and PDF export functionality
- Replaced `html2canvas` with `html2canvas-pro` for improved rendering in document capture.
- Enhanced error handling in print functions to provide user feedback on print failures.
- Introduced new `docs-page-capture` module to streamline page capture logic for PDF exports.
- Refactored PDF export process to utilize captured canvases, improving performance and reliability.
- Updated print styles for better document layout during printing and PDF generation.
2026-06-15 17:53:27 +02:00

986 lines
32 KiB
TypeScript

"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { ReactNode } from "react"
import { useEditor, EditorContent } from "@tiptap/react"
import { HocuspocusProvider } from "@hocuspocus/provider"
import * as Y from "yjs"
import { toast } from "sonner"
import { DocsChrome } from "@/components/drive/richtext/docs-chrome"
import { DocsEditorWorkspace } from "@/components/drive/richtext/docs-editor-workspace"
import { DocsLoadingSplash } from "@/components/drive/richtext/docs-loading-splash"
import { DocsStatusBar } from "@/components/drive/richtext/docs-page-view"
import { DocsToolbar } from "@/components/drive/richtext/docs-toolbar"
import { buildRichTextExtensions, RICHTEXT_EDITOR_CLASS } from "@/lib/drive/richtext-extensions"
import type { RichTextSaveStatus, RichTextSessionResponse } from "@/lib/drive/richtext-types"
import type { DriveShare, DriveFileInfo } from "@/lib/api/types"
import { useDocsEditMenu } from "@/lib/drive/use-docs-edit-menu"
import { useDocsFileMenu } from "@/lib/drive/use-docs-file-menu"
import { useDocsFormatMenu } from "@/lib/drive/use-docs-format-menu"
import { useDocsInsertMenu } from "@/lib/drive/use-docs-insert-menu"
import type { DocsViewMenuActions, DocsViewMenuState } from "@/components/drive/richtext/docs-view-menu"
import { useDocsViewSettings } from "@/lib/drive/docs-view-settings"
import { useDocsKeyboardShortcutsStore } from "@/lib/stores/docs-keyboard-shortcuts-store"
import { useCollabPresence } from "@/lib/drive/use-collab-presence"
import { apiClient } from "@/lib/api/client"
import { driveDownloadApiPath } from "@/lib/api/drive-download"
import { buildRegionPatch } from "@/lib/drive/docs-header-footer-layout"
import {
buildPageSetupForFormat,
buildPageSetupFromDraft,
resolveDocumentPageLayout,
type DocPageSetup,
} from "@/lib/drive/doc-page-setup"
import { readUserPageSetupDefaults } from "@/lib/drive/docs-page-defaults"
import { ensureMinimalTipTapDoc, isEmptyTipTapDoc } from "@/lib/drive/richtext-content"
import { importFileToTipTap } from "@/lib/drive/richtext-import"
import { migrateBase64ImagesInContent } from "@/lib/drive/docs-graphic-assets"
import { isUltidocPath } from "@/lib/drive/richtext-formats"
import { defaultDocumentParagraphStyles, type DocParagraphStylesCatalog } from "@/lib/drive/docs-paragraph-styles"
import { DocsParagraphStylesProvider } from "@/lib/drive/docs-paragraph-styles-context"
import { useDocsParagraphStyles } from "@/lib/drive/use-docs-paragraph-styles"
import {
resolveRichTextDocumentLoadingPhase,
type DocsLoadingPhase,
} from "@/lib/drive/docs-loading-phase"
import { buildDocsExportSnapshot } from "@/lib/drive/docs-export-snapshot"
import { printDocsDocument } from "@/lib/drive/docs-print"
import { DocsAiPanel, DocsAiPanelToggle } from "@/components/ai/docs-ai-panel"
import { useDocsAiPanelStore } from "@/lib/ai/use-docs-ai-panel"
import { cn } from "@/lib/utils"
const SAVE_DEBOUNCE_MS = 2000
/** Align with Hocuspocus store debounce + buffer */
const COLLAB_SAVE_IDLE_MS = 2000
const PAGE_SETUP_DEBOUNCE_MS = 1500
export type RichTextDocsChromeProps = {
title: string
onRename?: (next: string) => Promise<void>
renameDisabled?: boolean
backHref?: string
backLabel?: string
showBack?: boolean
shares?: DriveShare[]
onShareClick?: () => void
showShare?: boolean
showAccount?: boolean
trailing?: ReactNode
moveFile?: DriveFileInfo
onFileMoved?: (newPath: string) => void
file?: DriveFileInfo
onRenameRequest?: () => void
renameSignal?: number
}
export function RichTextDocumentEditor({
session,
mode,
userName,
userColor,
onSaveStatus,
fetchSourceBytes,
importApi,
chrome,
deferSplash = false,
onLoadingChange,
}: {
session: RichTextSessionResponse
mode: "edit" | "view"
userName: string
userColor: string
onSaveStatus?: (status: RichTextSaveStatus) => void
fetchSourceBytes?: (path: string) => Promise<ArrayBuffer>
importApi?: (body: {
source_path: string
content: Record<string, unknown>
pageSetup?: DocPageSetup | null
}) => Promise<void>
chrome?: RichTextDocsChromeProps
deferSplash?: boolean
onLoadingChange?: (loading: boolean, phase: DocsLoadingPhase) => void
}) {
const editable = mode === "edit"
const collaboration = session.collaboration && Boolean(session.wsUrl && session.token)
const ydocRef = useRef<Y.Doc | null>(null)
if (collaboration && !ydocRef.current) {
ydocRef.current = new Y.Doc()
}
const ydoc = collaboration ? ydocRef.current : null
const [provider, setProvider] = useState<HocuspocusProvider | null>(null)
const [collabSynced, setCollabSynced] = useState(false)
const [collabError, setCollabError] = useState<string | null>(null)
const [importDone, setImportDone] = useState(!session.importRequired)
const [contentImportPending, setContentImportPending] = useState(session.importRequired)
const [importedContent, setImportedContent] = useState<Record<string, unknown> | null>(null)
const [documentParagraphStyles, setDocumentParagraphStyles] = useState<DocParagraphStylesCatalog>(
() => session.paragraphStyles ?? defaultDocumentParagraphStyles()
)
const [documentPageSetup, setDocumentPageSetup] = useState<DocPageSetup | null>(
session.pageSetup ?? null
)
const [saveStatus, setSaveStatus] = useState<RichTextSaveStatus>("idle")
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 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 reloadAfterReimportRef = useRef(false)
const purgeReimportingRef = useRef(false)
const providerRef = useRef<HocuspocusProvider | null>(null)
const {
settings,
setPageFormatId,
setZoom,
toggleSpellcheck,
toggleChromeCollapsed,
setEditorMode,
setCommentsDisplay,
toggleOutlineSidebarExpanded,
toggleShowLayout,
toggleShowRuler,
toggleShowEquationToolbar,
toggleShowNonPrintableChars,
} = useDocsViewSettings()
const shellRef = useRef<HTMLDivElement>(null)
const getPageStackElementRef = useRef<() => HTMLElement | null>(() => null)
const presenceUsers = useCollabPresence(provider, { name: userName, color: userColor })
const pageLayout = useMemo(
() => resolveDocumentPageLayout(documentPageSetup, settings.pageFormatId),
[documentPageSetup, settings.pageFormatId]
)
const activePageFormatId = pageLayout.format.id
const reportSaveStatus = useCallback(
(status: RichTextSaveStatus) => {
saveStatusRef.current = status
setSaveStatus(status)
onSaveStatus?.(status)
},
[onSaveStatus]
)
const persistPageSetup = useCallback(
async (setup: DocPageSetup) => {
if (!editable) return
try {
const body = JSON.stringify({ pageSetup: setup })
if (session.saveUrl) {
const res = await fetch(session.saveUrl, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body,
})
if (!res.ok) throw new Error("save failed")
} else {
await apiClient.put("/richtext/save", {
path: session.canonicalPath,
pageSetup: setup,
})
}
reportSaveStatus("saved")
} catch {
reportSaveStatus("error")
}
},
[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(
(formatId: typeof settings.pageFormatId) => {
schedulePageSetupPatch(buildPageSetupForFormat(formatId, documentPageSetupRef.current), {
immediate: true,
})
},
[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(() => {
if (session.pageSetup) setDocumentPageSetup(session.pageSetup)
}, [session.pageSetup])
useEffect(() => {
if (session.pageSetup) return
setDocumentPageSetup((current) => {
if (current) return current
const setup = buildPageSetupFromDraft(
readUserPageSetupDefaults(settings.pageFormatId),
null
)
if (setup.formatId) setPageFormatId(setup.formatId)
return setup
})
}, [session.pageSetup, settings.pageFormatId, setPageFormatId])
const handlePageCountChange = useCallback((count: number) => {
setPageCount(count)
setCurrentPage((page) => Math.min(page, count))
}, [])
const handleCurrentPageChange = useCallback((page: number) => {
setCurrentPage(page)
}, [])
const handlePurgeSidecarAndReimport = useCallback(async () => {
if (!editable) return
if (!session.sourcePath) {
toast.error("Chemin source introuvable — ouvrez le document depuis le fichier DOCX")
return
}
if (isUltidocPath(session.sourcePath)) {
toast.error("Aucun fichier source à réimporter")
return
}
if (
!window.confirm(
"Supprimer le sidecar (.ultidoc.json) et réimporter le document source ? Cette action est temporaire (dev)."
)
) {
return
}
reportSaveStatus("saving")
purgeReimportingRef.current = true
reloadAfterReimportRef.current = true
try {
providerRef.current?.destroy()
providerRef.current = null
setProvider(null)
setCollabSynced(false)
if (ydocRef.current) {
ydocRef.current.destroy()
ydocRef.current = collaboration ? new Y.Doc() : null
}
await apiClient.delete(`/drive/files${session.canonicalPath}`)
setDocumentPageSetup(null)
setImportedContent(null)
setImportDone(false)
setContentImportPending(true)
toast.success("Sidecar purgé — réimport en cours…")
} catch {
purgeReimportingRef.current = false
reloadAfterReimportRef.current = false
reportSaveStatus("error")
toast.error("Impossible de purger le sidecar")
}
}, [collaboration, editable, reportSaveStatus, session.canonicalPath, session.sourcePath])
const markCollabDirty = useCallback(() => {
if (saveStatusRef.current !== "saving") {
reportSaveStatus("saving")
}
if (saveIdleTimer.current) clearTimeout(saveIdleTimer.current)
saveIdleTimer.current = setTimeout(() => {
reportSaveStatus("saved")
}, COLLAB_SAVE_IDLE_MS)
}, [reportSaveStatus])
useEffect(() => {
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current)
if (saveIdleTimer.current) clearTimeout(saveIdleTimer.current)
if (pageSetupTimer.current) clearTimeout(pageSetupTimer.current)
}
}, [])
useEffect(() => {
setContentImportPending(session.importRequired)
setImportDone(!session.importRequired)
}, [session.importRequired, session.canonicalPath])
useEffect(() => {
if (session.importRequired || !session.sourcePath) return
let cancelled = false
void (async () => {
try {
let parsed: { content?: Record<string, unknown> }
if (session.documentUrl) {
const res = await fetch(session.documentUrl)
if (!res.ok) return
parsed = JSON.parse(await res.text()) as { content?: Record<string, unknown> }
} else {
const blob = await apiClient.getBlob(driveDownloadApiPath(session.canonicalPath))
parsed = JSON.parse(await blob.text()) as { content?: Record<string, unknown> }
}
if (cancelled || !isEmptyTipTapDoc(parsed.content)) return
setContentImportPending(true)
setImportDone(false)
} catch {
/* keep current import flags */
}
})()
return () => {
cancelled = true
}
}, [session.canonicalPath, session.documentUrl, session.importRequired, session.sourcePath])
useEffect(() => {
if (!contentImportPending || importDone) return
let cancelled = false
void (async () => {
reportSaveStatus("saving")
try {
const source = session.sourcePath
if (!source) {
throw new Error("Chemin source manquant pour le réimport")
}
const buf = fetchSourceBytes
? await fetchSourceBytes(source)
: await (await apiClient.getBlob(driveDownloadApiPath(source))).arrayBuffer()
const imported = await importFileToTipTap(source.split("/").pop() ?? "file.docx", buf)
if (cancelled) return
const content = ensureMinimalTipTapDoc(imported.content as Record<string, unknown>)
const payload = {
source_path: source,
content,
pageSetup: imported.pageSetup ?? undefined,
}
if (importApi) {
await importApi(payload)
} else {
await apiClient.post("/richtext/import", payload)
}
if (!cancelled) {
if (imported.pageSetup) setDocumentPageSetup(imported.pageSetup)
if (reloadAfterReimportRef.current) {
reloadAfterReimportRef.current = false
window.location.reload()
return
}
setImportedContent(content)
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é")
}
}
})()
return () => {
cancelled = true
}
}, [
contentImportPending,
importDone,
session.sourcePath,
fetchSourceBytes,
importApi,
reportSaveStatus,
])
useEffect(() => {
if (purgeReimportingRef.current) return
if (!collaboration || !ydoc || !importDone) return
setCollabSynced(false)
setCollabError(null)
const p = new HocuspocusProvider({
url: session.wsUrl,
name: session.roomId,
token: session.token,
document: ydoc,
sessionAwareness: false,
onSynced: () => setCollabSynced(true),
onAuthenticationFailed: ({ reason }) => {
setCollabError(reason ?? "Authentification collaboration refusée")
setCollabSynced(false)
},
})
providerRef.current = p
setProvider(p)
return () => {
p.destroy()
providerRef.current = null
setProvider(null)
setCollabSynced(false)
}
}, [collaboration, importDone, session.roomId, session.token, session.wsUrl, ydoc])
const persistDocument = useCallback(
async (json: Record<string, unknown>) => {
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 savePromise = session.saveUrl
? fetch(session.saveUrl, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body,
}).then((res) => {
if (!res.ok) throw new Error("save failed")
})
: apiClient.put("/richtext/save", { path: session.canonicalPath, document: content })
await savePromise
},
[session.canonicalPath, session.saveUrl]
)
const scheduleSave = useCallback(
(json: Record<string, unknown>, options?: { immediate?: boolean }) => {
if (!editable || collaboration) return
if (saveTimer.current) clearTimeout(saveTimer.current)
if (saveStatusRef.current !== "saving") {
reportSaveStatus("saving")
}
const runSave = () => {
void persistDocument(json)
.then(() => reportSaveStatus("saved"))
.catch(() => reportSaveStatus("error"))
}
if (options?.immediate) {
runSave()
return
}
saveTimer.current = setTimeout(runSave, SAVE_DEBOUNCE_MS)
},
[collaboration, editable, persistDocument, reportSaveStatus]
)
const collabReady = !collaboration || (Boolean(provider) && collabSynced)
const editorEnabled = importDone && collabReady
const extensions = useMemo(
() =>
buildRichTextExtensions({
collaboration: collaboration && ydoc ? { document: ydoc } : undefined,
collaborationCaret:
collaboration && provider
? { provider, user: { name: userName, color: userColor } }
: undefined,
editable,
}),
[collaboration, ydoc, provider, userName, userColor, editable]
)
const editor = useEditor(
{
immediatelyRender: false,
editable,
extensions,
editorProps: {
attributes: {
class: RICHTEXT_EDITOR_CLASS,
},
},
onUpdate: ({ editor: ed }) => {
if (collaboration) {
markCollabDirty()
return
}
scheduleSave(ed.getJSON() as Record<string, unknown>)
},
},
[editorEnabled, extensions, collaboration, markCollabDirty, scheduleSave]
)
useEffect(() => {
if (!editor || editor.isDestroyed) return
const flushAfterDrawSave = () => {
if (!editable) return
const json = editor.getJSON() as Record<string, unknown>
if (collaboration) {
if (saveStatusRef.current !== "saving") {
reportSaveStatus("saving")
}
void persistDocument(json)
.then(() => reportSaveStatus("saved"))
.catch(() => reportSaveStatus("error"))
return
}
scheduleSave(json, { immediate: true })
}
window.addEventListener("ultidocs:graphic-draw-saved", flushAfterDrawSave)
return () => window.removeEventListener("ultidocs:graphic-draw-saved", flushAfterDrawSave)
}, [collaboration, editable, editor, persistDocument, reportSaveStatus, scheduleSave])
useEffect(() => {
if (!editor || editor.isDestroyed) return
const syncSpellcheck = () => {
if (editor.isDestroyed || !editor.isInitialized) return
const dom = editor.view.dom
dom.spellcheck = settings.spellcheck
if (settings.spellcheck) {
dom.setAttribute("spellcheck", "true")
dom.removeAttribute("autocorrect")
dom.removeAttribute("autocapitalize")
} else {
dom.setAttribute("spellcheck", "false")
dom.setAttribute("autocorrect", "off")
dom.setAttribute("autocapitalize", "off")
}
}
syncSpellcheck()
editor.on("create", syncSpellcheck)
return () => {
editor.off("create", syncSpellcheck)
}
}, [editor, settings.spellcheck])
const editMenu = useDocsEditMenu({
editor,
disabled: !editable,
})
const insertMenu = useDocsInsertMenu({
editor,
disabled: !editable,
pageSetup: documentPageSetup,
onPageSetupPatch: handlePageSetupPatch,
})
const paragraphStyles = useDocsParagraphStyles({
editor,
initialDocumentStyles: documentParagraphStyles,
editable,
canonicalPath: session.canonicalPath,
saveUrl: session.saveUrl,
})
useEffect(() => {
setDocumentParagraphStyles(paragraphStyles.state.documentStyles)
}, [paragraphStyles.state.documentStyles])
useEffect(() => {
if (session.paragraphStyles) setDocumentParagraphStyles(session.paragraphStyles)
}, [session.paragraphStyles])
const paragraphStylesContextValue = useMemo(
() => ({
state: paragraphStyles.state,
applyStyle: paragraphStyles.applyStyle,
updateStyleFromSelection: paragraphStyles.updateStyleFromSelection,
createUserStyle: paragraphStyles.createUserStyle,
updateDocumentStyle: paragraphStyles.updateDocumentStyle,
}),
[paragraphStyles]
)
const getExportSnapshot = useCallback(() => {
if (!editor || !chrome?.file) return null
return buildDocsExportSnapshot({
editor,
sourceName: chrome.file.name,
title: chrome.title,
pageSetup: documentPageSetup,
fallbackFormatId: settings.pageFormatId,
paragraphStyles: paragraphStyles.state.mergedCatalog,
pageCount,
getPageStackElement: () => getPageStackElementRef.current(),
})
}, [
chrome?.file,
chrome?.title,
documentPageSetup,
editor,
pageCount,
paragraphStyles.state.mergedCatalog,
settings.pageFormatId,
])
const handlePrintDocument = useCallback(async () => {
const snapshot = getExportSnapshot()
if (!snapshot) {
toast.error("Impossible d'imprimer le document")
return
}
try {
await printDocsDocument(snapshot)
} catch (error) {
console.error("[docs] print failed", error)
toast.error("Impossible d'imprimer le document")
}
}, [getExportSnapshot])
const fileMenu = useDocsFileMenu({
file: chrome?.file,
editor,
pageSetup: documentPageSetup,
fallbackFormatId: settings.pageFormatId,
getExportSnapshot,
onPageSetupApply: (setup) => {
documentPageSetupRef.current = setup
schedulePageSetupPatch(setup, { immediate: true })
},
onPurgeSidecarAndReimport: () => void handlePurgeSidecarAndReimport(),
onShareClick: chrome?.onShareClick,
onRenameRequest: chrome?.onRenameRequest,
onFileMoved: chrome?.onFileMoved,
disabled: !editable,
})
const formatMenu = useDocsFormatMenu({
editor,
disabled: !editable,
onPageSetup: fileMenu.actions.onPageSetup,
})
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 closeDocsAiPanel = useDocsAiPanelStore((s) => s.closePanel)
useEffect(() => {
closeDocsAiPanel()
}, [session.canonicalPath, closeDocsAiPanel])
const chromeProps = chrome
? {
...chrome,
trailing: (
<>
{chrome.trailing}
<DocsAiPanelToggle />
</>
),
fileMenuActions: fileMenu.actions,
fileMenuDialogs: fileMenu.dialogs,
fileMenuDisabled: fileMenu.disabled,
editMenuActions: editMenu.actions,
editMenuState: editMenu.state,
editMenuDisabled: editMenu.disabled,
insertMenuActions: insertMenu.actions,
insertMenuDialogs: insertMenu.dialogs,
insertMenuDisabled: insertMenu.disabled,
insertMenuPageElementsEnabled: insertMenu.pageElementsEnabled,
formatMenuActions: formatMenu.actions,
formatMenuState: formatMenu.state,
formatMenuDisabled: formatMenu.disabled,
viewMenuActions,
viewMenuState,
viewMenuDisabled: false,
}
: undefined
useEffect(() => {
if (!editor || editor.isDestroyed) return
const canEdit = editable && settings.editorMode !== "view"
editor.setEditable(canEdit)
}, [editor, editable, settings.editorMode])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const id = useDocsKeyboardShortcutsStore.getState().matchEvent(
event,
(definition) =>
definition.scope === "document" && definition.handler === "custom"
)
if (id === "view.showNonPrintable") {
event.preventDefault()
toggleShowNonPrintableChars()
}
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [toggleShowNonPrintableChars])
useEffect(() => {
if (!editor || collaboration || !importDone) return
if (importedContent) {
editor.commands.setContent(importedContent)
setImportedContent(null)
return
}
if (contentImportPending) return
let cancelled = false
void (async () => {
try {
let parsed: { content?: Record<string, unknown>; pageSetup?: DocPageSetup }
if (session.documentUrl) {
const res = await fetch(session.documentUrl)
if (!res.ok) throw new Error("load failed")
parsed = JSON.parse(await res.text()) as { content?: Record<string, unknown>; pageSetup?: DocPageSetup }
} else {
const blob = await apiClient.getBlob(driveDownloadApiPath(session.canonicalPath))
parsed = JSON.parse(await blob.text()) as { content?: Record<string, unknown>; pageSetup?: DocPageSetup }
}
if (!cancelled && parsed.pageSetup) {
setDocumentPageSetup(parsed.pageSetup)
} else if (!cancelled) {
setDocumentPageSetup(
(current) =>
current ??
buildPageSetupFromDraft(readUserPageSetupDefaults(settings.pageFormatId), null)
)
}
if (!cancelled && parsed.content && !isEmptyTipTapDoc(parsed.content)) {
editor.commands.setContent(parsed.content)
} else if (!cancelled && session.sourcePath) {
setContentImportPending(true)
setImportDone(false)
}
} catch {
/* blank */
}
})()
return () => {
cancelled = true
}
}, [
editor,
collaboration,
importDone,
importedContent,
contentImportPending,
session.canonicalPath,
session.documentUrl,
session.sourcePath,
settings.pageFormatId,
])
const documentLoading = !editorEnabled || !editor
const loadingPhase = resolveRichTextDocumentLoadingPhase({
contentImportPending,
importDone,
importRequired: session.importRequired,
collaboration: Boolean(collaboration),
collabSynced,
})
useEffect(() => {
if (!deferSplash) return
onLoadingChange?.(documentLoading, loadingPhase)
}, [deferSplash, documentLoading, loadingPhase, onLoadingChange])
if (collabError) {
return (
<div className="flex h-full items-center justify-center p-6 text-sm text-destructive">
Collaboration indisponible : {collabError}
</div>
)
}
if (!deferSplash && documentLoading) {
return (
<DocsLoadingSplash
phase={loadingPhase}
title={chromeProps?.title}
/>
)
}
if (deferSplash && documentLoading) {
return null
}
if (!editor) {
return null
}
return (
<DocsParagraphStylesProvider value={paragraphStylesContextValue}>
<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 ? (
<DocsChrome
{...chromeProps}
saveStatus={saveStatus}
presenceUsers={presenceUsers}
/>
) : null}
{chrome ? (
<div className="flex min-h-0 min-w-0 flex-1 flex-row">
<DocsEditorWorkspace
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}
onPageStackReady={(getPageStack) => {
getPageStackElementRef.current = getPageStack
}}
toolbarShellClassName={
settings.chromeCollapsed ? "docs-toolbar-shell--collapsed" : undefined
}
toolbar={
editable ? (
<DocsToolbar
editor={regionEditor ?? editor}
zoom={settings.zoom}
onZoomChange={setZoom}
spellcheck={settings.spellcheck}
onToggleSpellcheck={toggleSpellcheck}
showChromeToggle={Boolean(chrome)}
chromeCollapsed={settings.chromeCollapsed}
onToggleChromeCollapsed={toggleChromeCollapsed}
onPrint={() => void handlePrintDocument()}
embedded
/>
) : null
}
/>
<DocsAiPanel
editor={editor}
documentPath={session.canonicalPath}
documentTitle={chromeProps?.title ?? "Document"}
sourcePath={session.sourcePath}
editable={editable && settings.editorMode !== "view"}
/>
</div>
) : (
<div className="min-h-0 flex-1 overflow-auto">
<EditorContent editor={editor} className="h-full" />
</div>
)}
{chrome ? (
<DocsStatusBar
pageLayout={pageLayout}
pageCount={pageCount}
currentPage={currentPage}
/>
) : null}
</div>
</DocsParagraphStylesProvider>
)
}