818 lines
26 KiB
TypeScript
818 lines
26 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 { DocsStatusBar } from "@/components/drive/richtext/docs-page-view"
|
|
import { DocsToolbar } from "@/components/drive/richtext/docs-toolbar"
|
|
import { buildRichTextExtensions, RICHTEXT_EDITOR_CLASS } from "@/lib/drive/richtext-extensions"
|
|
import type { RichTextSaveStatus, RichTextSessionResponse } from "@/lib/drive/richtext-types"
|
|
import type { DriveShare, DriveFileInfo } from "@/lib/api/types"
|
|
import { useDocsEditMenu } from "@/lib/drive/use-docs-edit-menu"
|
|
import { useDocsFileMenu } from "@/lib/drive/use-docs-file-menu"
|
|
import type { DocsViewMenuActions, DocsViewMenuState } from "@/components/drive/richtext/docs-view-menu"
|
|
import { useDocsViewSettings } from "@/lib/drive/docs-view-settings"
|
|
import { useDocsKeyboardShortcutsStore } from "@/lib/stores/docs-keyboard-shortcuts-store"
|
|
import { useCollabPresence } from "@/lib/drive/use-collab-presence"
|
|
import { apiClient } from "@/lib/api/client"
|
|
import { driveDownloadApiPath } from "@/lib/api/drive-download"
|
|
import { buildRegionPatch } from "@/lib/drive/docs-header-footer-layout"
|
|
import {
|
|
buildPageSetupForFormat,
|
|
buildPageSetupFromDraft,
|
|
resolveDocumentPageLayout,
|
|
type DocPageSetup,
|
|
} from "@/lib/drive/doc-page-setup"
|
|
import { readUserPageSetupDefaults } from "@/lib/drive/docs-page-defaults"
|
|
import { isEmptyTipTapDoc } from "@/lib/drive/richtext-content"
|
|
import { importFileToTipTap } from "@/lib/drive/richtext-import"
|
|
import { migrateBase64ImagesInContent } from "@/lib/drive/docs-graphic-assets"
|
|
import { isUltidocPath } from "@/lib/drive/richtext-formats"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
const SAVE_DEBOUNCE_MS = 2000
|
|
/** Align with Hocuspocus store debounce + buffer */
|
|
const COLLAB_SAVE_IDLE_MS = 2000
|
|
const PAGE_SETUP_DEBOUNCE_MS = 1500
|
|
|
|
export type RichTextDocsChromeProps = {
|
|
title: string
|
|
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,
|
|
}: {
|
|
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
|
|
}) {
|
|
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 [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 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
|
|
if (isEmptyTipTapDoc(imported.content as Record<string, unknown>)) {
|
|
throw new Error("Le fichier source n'a produit aucun contenu importable")
|
|
}
|
|
const payload = {
|
|
source_path: source,
|
|
content: imported.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(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é")
|
|
}
|
|
}
|
|
})()
|
|
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 scheduleSave = useCallback(
|
|
(json: Record<string, unknown>) => {
|
|
if (!editable || collaboration) return
|
|
if (saveTimer.current) clearTimeout(saveTimer.current)
|
|
if (saveStatusRef.current !== "saving") {
|
|
reportSaveStatus("saving")
|
|
}
|
|
saveTimer.current = setTimeout(() => {
|
|
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 savePromise = session.saveUrl
|
|
? fetch(session.saveUrl, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body,
|
|
}).then((res) => {
|
|
if (!res.ok) throw new Error("save failed")
|
|
})
|
|
: apiClient.put("/richtext/save", { path: session.canonicalPath, document: content })
|
|
await savePromise
|
|
reportSaveStatus("saved")
|
|
})().catch(() => reportSaveStatus("error"))
|
|
}, SAVE_DEBOUNCE_MS)
|
|
},
|
|
[collaboration, editable, reportSaveStatus, session.canonicalPath, session.saveUrl]
|
|
)
|
|
|
|
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 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 fileMenu = useDocsFileMenu({
|
|
file: chrome?.file,
|
|
editor,
|
|
pageSetup: documentPageSetup,
|
|
fallbackFormatId: settings.pageFormatId,
|
|
onPageSetupApply: (setup) => {
|
|
documentPageSetupRef.current = setup
|
|
schedulePageSetupPatch(setup, { immediate: true })
|
|
},
|
|
onPurgeSidecarAndReimport: () => void handlePurgeSidecarAndReimport(),
|
|
onShareClick: chrome?.onShareClick,
|
|
onRenameRequest: chrome?.onRenameRequest,
|
|
onFileMoved: chrome?.onFileMoved,
|
|
disabled: !editable,
|
|
})
|
|
|
|
const editMenu = useDocsEditMenu({
|
|
editor,
|
|
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
|
|
? {
|
|
...chrome,
|
|
fileMenuActions: fileMenu.actions,
|
|
fileMenuDialogs: fileMenu.dialogs,
|
|
fileMenuDisabled: fileMenu.disabled,
|
|
editMenuActions: editMenu.actions,
|
|
editMenuState: editMenu.state,
|
|
editMenuDisabled: editMenu.disabled,
|
|
viewMenuActions,
|
|
viewMenuState,
|
|
viewMenuDisabled: false,
|
|
}
|
|
: undefined
|
|
|
|
useEffect(() => {
|
|
if (!editor || editor.isDestroyed) return
|
|
const canEdit = editable && settings.editorMode !== "view"
|
|
editor.setEditable(canEdit)
|
|
}, [editor, editable, settings.editorMode])
|
|
|
|
useEffect(() => {
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
const id = useDocsKeyboardShortcutsStore.getState().matchEvent(
|
|
event,
|
|
(definition) =>
|
|
definition.scope === "document" && definition.handler === "custom"
|
|
)
|
|
if (id === "view.showNonPrintable") {
|
|
event.preventDefault()
|
|
toggleShowNonPrintableChars()
|
|
}
|
|
}
|
|
window.addEventListener("keydown", onKeyDown)
|
|
return () => window.removeEventListener("keydown", onKeyDown)
|
|
}, [toggleShowNonPrintableChars])
|
|
|
|
useEffect(() => {
|
|
if (!editor || collaboration || !importDone) return
|
|
|
|
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,
|
|
])
|
|
|
|
if (collabError) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center p-6 text-sm text-destructive">
|
|
Collaboration indisponible : {collabError}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!editorEnabled || !editor) {
|
|
const statusText =
|
|
contentImportPending && !importDone
|
|
? "Import du document…"
|
|
: session.importRequired && !importDone
|
|
? "Import du document…"
|
|
: collaboration && !collabSynced
|
|
? "Connexion à la collaboration…"
|
|
: "Connexion…"
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
{chromeProps && !settings.chromeCollapsed ? (
|
|
<DocsChrome
|
|
{...chromeProps}
|
|
saveStatus={saveStatus}
|
|
presenceUsers={presenceUsers}
|
|
/>
|
|
) : null}
|
|
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
|
{statusText}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<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 ? (
|
|
<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}
|
|
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}
|
|
embedded
|
|
/>
|
|
) : null
|
|
}
|
|
/>
|
|
) : (
|
|
<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>
|
|
)
|
|
}
|