This commit is contained in:
parent
79bb6193fc
commit
8e420509a8
@ -9,7 +9,7 @@ NEXT_PUBLIC_WS_URL=ws://localhost/ws
|
|||||||
NEXT_PUBLIC_OIDC_ISSUER=http://localhost/auth/application/o/ulti/
|
NEXT_PUBLIC_OIDC_ISSUER=http://localhost/auth/application/o/ulti/
|
||||||
NEXT_PUBLIC_OIDC_CLIENT_ID=ulti-backend
|
NEXT_PUBLIC_OIDC_CLIENT_ID=ulti-backend
|
||||||
# URL publique affichée dans les redirects OIDC (navigateur) — utiliser localhost, pas 0.0.0.0
|
# URL publique affichée dans les redirects OIDC (navigateur) — utiliser localhost, pas 0.0.0.0
|
||||||
# URL publique navigateur (suite nginx) — pas :3000 si tu passes par http://localhost/mail
|
# URL publique navigateur (suite nginx) — pas :3004 si tu passes par http://localhost/mail
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost
|
NEXT_PUBLIC_APP_URL=http://localhost
|
||||||
# Cookies session Secure (auto: true seulement si NEXT_PUBLIC_APP_URL est https://)
|
# Cookies session Secure (auto: true seulement si NEXT_PUBLIC_APP_URL est https://)
|
||||||
# COOKIE_SECURE=false
|
# COOKIE_SECURE=false
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import type { PublicShareRootType } from "@/lib/drive/public-share-url"
|
|||||||
import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title"
|
import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title"
|
||||||
import { getGuestEditorIdentity } from "@/lib/drive/guest-editor-identity"
|
import { getGuestEditorIdentity } from "@/lib/drive/guest-editor-identity"
|
||||||
import type { RichTextSessionResponse } from "@/lib/drive/richtext-types"
|
import type { RichTextSessionResponse } from "@/lib/drive/richtext-types"
|
||||||
|
import type { DocPageSetup } from "@/lib/drive/doc-page-setup"
|
||||||
import { fetchPublicShareBlob } from "@/lib/api/public-share"
|
import { fetchPublicShareBlob } from "@/lib/api/public-share"
|
||||||
|
|
||||||
function fileNameFromPath(filePath: string, fallback?: string): string {
|
function fileNameFromPath(filePath: string, fallback?: string): string {
|
||||||
@ -99,7 +100,11 @@ export function PublicRichTextEditor({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const importApi = useCallback(
|
const importApi = useCallback(
|
||||||
async (body: { source_path: string; content: Record<string, unknown> }) => {
|
async (body: {
|
||||||
|
source_path: string
|
||||||
|
content: Record<string, unknown>
|
||||||
|
pageSetup?: DocPageSetup | null
|
||||||
|
}) => {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/v1/drive/public/shares/${encodeURIComponent(token)}/richtext/import`,
|
`/api/v1/drive/public/shares/${encodeURIComponent(token)}/richtext/import`,
|
||||||
{
|
{
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type { ReactNode } from "react"
|
|||||||
import { useEditor, EditorContent } from "@tiptap/react"
|
import { useEditor, EditorContent } from "@tiptap/react"
|
||||||
import { HocuspocusProvider } from "@hocuspocus/provider"
|
import { HocuspocusProvider } from "@hocuspocus/provider"
|
||||||
import * as Y from "yjs"
|
import * as Y from "yjs"
|
||||||
|
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 { DocsPageView, DocsStatusBar } from "@/components/drive/richtext/docs-page-view"
|
||||||
import { DocsToolbar } from "@/components/drive/richtext/docs-toolbar"
|
import { DocsToolbar } from "@/components/drive/richtext/docs-toolbar"
|
||||||
@ -17,7 +18,16 @@ import { useDocsViewSettings } from "@/lib/drive/docs-view-settings"
|
|||||||
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 {
|
||||||
|
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 { importFileToTipTap } from "@/lib/drive/richtext-import"
|
||||||
|
import { isUltidocPath } from "@/lib/drive/richtext-formats"
|
||||||
|
|
||||||
const SAVE_DEBOUNCE_MS = 2000
|
const SAVE_DEBOUNCE_MS = 2000
|
||||||
/** Align with Hocuspocus store debounce + buffer */
|
/** Align with Hocuspocus store debounce + buffer */
|
||||||
@ -58,7 +68,11 @@ export function RichTextDocumentEditor({
|
|||||||
userColor: string
|
userColor: string
|
||||||
onSaveStatus?: (status: RichTextSaveStatus) => void
|
onSaveStatus?: (status: RichTextSaveStatus) => void
|
||||||
fetchSourceBytes?: (path: string) => Promise<ArrayBuffer>
|
fetchSourceBytes?: (path: string) => Promise<ArrayBuffer>
|
||||||
importApi?: (body: { source_path: string; content: Record<string, unknown> }) => Promise<void>
|
importApi?: (body: {
|
||||||
|
source_path: string
|
||||||
|
content: Record<string, unknown>
|
||||||
|
pageSetup?: DocPageSetup | null
|
||||||
|
}) => Promise<void>
|
||||||
chrome?: RichTextDocsChromeProps
|
chrome?: RichTextDocsChromeProps
|
||||||
}) {
|
}) {
|
||||||
const editable = mode === "edit"
|
const editable = mode === "edit"
|
||||||
@ -73,15 +87,26 @@ export function RichTextDocumentEditor({
|
|||||||
const [collabSynced, setCollabSynced] = useState(false)
|
const [collabSynced, setCollabSynced] = useState(false)
|
||||||
const [collabError, setCollabError] = useState<string | null>(null)
|
const [collabError, setCollabError] = useState<string | null>(null)
|
||||||
const [importDone, setImportDone] = useState(!session.importRequired)
|
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 [saveStatus, setSaveStatus] = useState<RichTextSaveStatus>("idle")
|
||||||
const [pageCount, setPageCount] = useState(1)
|
const [pageCount, setPageCount] = useState(1)
|
||||||
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 saveStatusRef = useRef<RichTextSaveStatus>("idle")
|
const saveStatusRef = useRef<RichTextSaveStatus>("idle")
|
||||||
|
const reloadAfterReimportRef = useRef(false)
|
||||||
|
|
||||||
const { settings, setPageFormatId, setZoom, toggleSpellcheck, toggleChromeCollapsed } =
|
const { settings, setPageFormatId, setZoom, toggleSpellcheck, toggleChromeCollapsed } =
|
||||||
useDocsViewSettings()
|
useDocsViewSettings()
|
||||||
const presenceUsers = useCollabPresence(provider, { name: userName, color: userColor })
|
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(
|
const reportSaveStatus = useCallback(
|
||||||
(status: RichTextSaveStatus) => {
|
(status: RichTextSaveStatus) => {
|
||||||
@ -92,10 +117,93 @@ export function RichTextDocumentEditor({
|
|||||||
[onSaveStatus]
|
[onSaveStatus]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const persistPageSetup = useCallback(
|
||||||
|
async (setup: DocPageSetup) => {
|
||||||
|
if (!editable) return
|
||||||
|
setDocumentPageSetup(setup)
|
||||||
|
if (setup.formatId) setPageFormatId(setup.formatId)
|
||||||
|
reportSaveStatus("saving")
|
||||||
|
try {
|
||||||
|
const body = JSON.stringify({ pageSetup: setup })
|
||||||
|
if (session.saveUrl) {
|
||||||
|
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, setPageFormatId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlePageFormatChange = useCallback(
|
||||||
|
(formatId: typeof settings.pageFormatId) => {
|
||||||
|
void persistPageSetup(buildPageSetupForFormat(formatId, documentPageSetup))
|
||||||
|
},
|
||||||
|
[documentPageSetup, persistPageSetup, settings.pageFormatId]
|
||||||
|
)
|
||||||
|
|
||||||
|
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) => {
|
const handlePageCountChange = useCallback((count: number) => {
|
||||||
setPageCount(count)
|
setPageCount(count)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handlePurgeSidecarAndReimport = useCallback(async () => {
|
||||||
|
if (!editable) return
|
||||||
|
const source = session.sourcePath || session.canonicalPath
|
||||||
|
if (!source || isUltidocPath(source)) {
|
||||||
|
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")
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/drive/files${session.canonicalPath}`)
|
||||||
|
setDocumentPageSetup(null)
|
||||||
|
setImportedContent(null)
|
||||||
|
reloadAfterReimportRef.current = collaboration
|
||||||
|
setImportDone(false)
|
||||||
|
setContentImportPending(true)
|
||||||
|
toast.success("Sidecar purgé — réimport en cours…")
|
||||||
|
} catch {
|
||||||
|
reportSaveStatus("error")
|
||||||
|
toast.error("Impossible de purger le sidecar")
|
||||||
|
}
|
||||||
|
}, [collaboration, editable, reportSaveStatus, session.canonicalPath, session.sourcePath])
|
||||||
|
|
||||||
const markCollabDirty = useCallback(() => {
|
const markCollabDirty = useCallback(() => {
|
||||||
if (saveStatusRef.current !== "saving") {
|
if (saveStatusRef.current !== "saving") {
|
||||||
reportSaveStatus("saving")
|
reportSaveStatus("saving")
|
||||||
@ -114,7 +222,38 @@ export function RichTextDocumentEditor({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session.importRequired || importDone) return
|
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
|
let cancelled = false
|
||||||
void (async () => {
|
void (async () => {
|
||||||
reportSaveStatus("saving")
|
reportSaveStatus("saving")
|
||||||
@ -123,17 +262,29 @@ export function RichTextDocumentEditor({
|
|||||||
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 content = await importFileToTipTap(source.split("/").pop() ?? "file.docx", buf)
|
const imported = await importFileToTipTap(source.split("/").pop() ?? "file.docx", buf)
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const payload = { source_path: source, content }
|
const payload = {
|
||||||
|
source_path: source,
|
||||||
|
content: imported.content,
|
||||||
|
pageSetup: imported.pageSetup ?? undefined,
|
||||||
|
}
|
||||||
if (importApi) {
|
if (importApi) {
|
||||||
await importApi(payload)
|
await importApi(payload)
|
||||||
} else {
|
} else {
|
||||||
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)
|
||||||
|
setContentImportPending(false)
|
||||||
setImportDone(true)
|
setImportDone(true)
|
||||||
reportSaveStatus("saved")
|
reportSaveStatus("saved")
|
||||||
|
if (reloadAfterReimportRef.current) {
|
||||||
|
reloadAfterReimportRef.current = false
|
||||||
|
window.location.reload()
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) reportSaveStatus("error")
|
if (!cancelled) reportSaveStatus("error")
|
||||||
@ -142,7 +293,7 @@ export function RichTextDocumentEditor({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [session, importDone, fetchSourceBytes, importApi, reportSaveStatus])
|
}, [contentImportPending, importDone, session, fetchSourceBytes, importApi, reportSaveStatus])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!collaboration || !ydoc || !importDone) return
|
if (!collaboration || !ydoc || !importDone) return
|
||||||
@ -263,8 +414,10 @@ export function RichTextDocumentEditor({
|
|||||||
const fileMenu = useDocsFileMenu({
|
const fileMenu = useDocsFileMenu({
|
||||||
file: chrome?.file,
|
file: chrome?.file,
|
||||||
editor,
|
editor,
|
||||||
pageFormatId: settings.pageFormatId,
|
pageSetup: documentPageSetup,
|
||||||
onPageFormatChange: setPageFormatId,
|
fallbackFormatId: settings.pageFormatId,
|
||||||
|
onPageSetupApply: (setup) => void persistPageSetup(setup),
|
||||||
|
onPurgeSidecarAndReimport: () => void handlePurgeSidecarAndReimport(),
|
||||||
onShareClick: chrome?.onShareClick,
|
onShareClick: chrome?.onShareClick,
|
||||||
onRenameRequest: chrome?.onRenameRequest,
|
onRenameRequest: chrome?.onRenameRequest,
|
||||||
onFileMoved: chrome?.onFileMoved,
|
onFileMoved: chrome?.onFileMoved,
|
||||||
@ -289,20 +442,43 @@ export function RichTextDocumentEditor({
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor || collaboration || !importDone || session.importRequired) return
|
if (!editor || collaboration || !importDone) return
|
||||||
|
|
||||||
|
if (importedContent) {
|
||||||
|
editor.commands.setContent(importedContent)
|
||||||
|
setImportedContent(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentImportPending) return
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
let parsed: { content?: Record<string, unknown> }
|
let parsed: { content?: Record<string, unknown>; pageSetup?: DocPageSetup }
|
||||||
if (session.documentUrl) {
|
if (session.documentUrl) {
|
||||||
const res = await fetch(session.documentUrl)
|
const res = await fetch(session.documentUrl)
|
||||||
if (!res.ok) throw new Error("load failed")
|
if (!res.ok) throw new Error("load failed")
|
||||||
parsed = JSON.parse(await res.text()) as { content?: Record<string, unknown> }
|
parsed = JSON.parse(await res.text()) as { content?: Record<string, unknown>; pageSetup?: DocPageSetup }
|
||||||
} else {
|
} else {
|
||||||
const blob = await apiClient.getBlob(driveDownloadApiPath(session.canonicalPath))
|
const blob = await apiClient.getBlob(driveDownloadApiPath(session.canonicalPath))
|
||||||
parsed = JSON.parse(await blob.text()) as { content?: Record<string, unknown> }
|
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)
|
||||||
}
|
}
|
||||||
if (!cancelled && parsed.content) editor.commands.setContent(parsed.content)
|
|
||||||
} catch {
|
} catch {
|
||||||
/* blank */
|
/* blank */
|
||||||
}
|
}
|
||||||
@ -310,7 +486,17 @@ export function RichTextDocumentEditor({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [editor, collaboration, importDone, session.canonicalPath, session.documentUrl, session.importRequired])
|
}, [
|
||||||
|
editor,
|
||||||
|
collaboration,
|
||||||
|
importDone,
|
||||||
|
importedContent,
|
||||||
|
contentImportPending,
|
||||||
|
session.canonicalPath,
|
||||||
|
session.documentUrl,
|
||||||
|
session.sourcePath,
|
||||||
|
settings.pageFormatId,
|
||||||
|
])
|
||||||
|
|
||||||
if (collabError) {
|
if (collabError) {
|
||||||
return (
|
return (
|
||||||
@ -322,7 +508,9 @@ export function RichTextDocumentEditor({
|
|||||||
|
|
||||||
if (!editorEnabled || !editor) {
|
if (!editorEnabled || !editor) {
|
||||||
const statusText =
|
const statusText =
|
||||||
session.importRequired && !importDone
|
contentImportPending && !importDone
|
||||||
|
? "Import du document…"
|
||||||
|
: session.importRequired && !importDone
|
||||||
? "Import du document…"
|
? "Import du document…"
|
||||||
: collaboration && !collabSynced
|
: collaboration && !collabSynced
|
||||||
? "Connexion à la collaboration…"
|
? "Connexion à la collaboration…"
|
||||||
@ -334,8 +522,8 @@ export function RichTextDocumentEditor({
|
|||||||
{...chromeProps}
|
{...chromeProps}
|
||||||
saveStatus={saveStatus}
|
saveStatus={saveStatus}
|
||||||
presenceUsers={presenceUsers}
|
presenceUsers={presenceUsers}
|
||||||
pageFormatId={settings.pageFormatId}
|
pageFormatId={activePageFormatId}
|
||||||
onPageFormatChange={setPageFormatId}
|
onPageFormatChange={handlePageFormatChange}
|
||||||
zoom={settings.zoom}
|
zoom={settings.zoom}
|
||||||
onZoomChange={setZoom}
|
onZoomChange={setZoom}
|
||||||
/>
|
/>
|
||||||
@ -354,8 +542,8 @@ export function RichTextDocumentEditor({
|
|||||||
{...chromeProps}
|
{...chromeProps}
|
||||||
saveStatus={saveStatus}
|
saveStatus={saveStatus}
|
||||||
presenceUsers={presenceUsers}
|
presenceUsers={presenceUsers}
|
||||||
pageFormatId={settings.pageFormatId}
|
pageFormatId={activePageFormatId}
|
||||||
onPageFormatChange={setPageFormatId}
|
onPageFormatChange={handlePageFormatChange}
|
||||||
zoom={settings.zoom}
|
zoom={settings.zoom}
|
||||||
onZoomChange={setZoom}
|
onZoomChange={setZoom}
|
||||||
/>
|
/>
|
||||||
@ -375,7 +563,7 @@ export function RichTextDocumentEditor({
|
|||||||
{chrome ? (
|
{chrome ? (
|
||||||
<DocsPageView
|
<DocsPageView
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pageFormatId={settings.pageFormatId}
|
pageLayout={pageLayout}
|
||||||
zoom={settings.zoom}
|
zoom={settings.zoom}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
onPageCountChange={handlePageCountChange}
|
onPageCountChange={handlePageCountChange}
|
||||||
@ -386,7 +574,7 @@ export function RichTextDocumentEditor({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{chrome ? (
|
{chrome ? (
|
||||||
<DocsStatusBar pageFormatId={settings.pageFormatId} pageCount={pageCount} />
|
<DocsStatusBar pageLayout={pageLayout} pageCount={pageCount} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -44,17 +44,19 @@ export function RichTextEditor({ fileId }: { fileId: string }) {
|
|||||||
const title = displayFileBaseName(fileName)
|
const title = displayFileBaseName(fileName)
|
||||||
useDriveDocumentTitle(title)
|
useDriveDocumentTitle(title)
|
||||||
|
|
||||||
const sessionReturnTo = readDriveEditorReturnTo()
|
const [backHref, setBackHref] = useState("/drive")
|
||||||
const backHref = useMemo(
|
|
||||||
() =>
|
useEffect(() => {
|
||||||
|
if (!displayPath) return
|
||||||
|
setBackHref(
|
||||||
resolveDriveEditReturnTo(
|
resolveDriveEditReturnTo(
|
||||||
null,
|
null,
|
||||||
displayPath,
|
displayPath,
|
||||||
(folderPath) => driveFolderHref("files", folderPath),
|
(folderPath) => driveFolderHref("files", folderPath),
|
||||||
sessionReturnTo
|
readDriveEditorReturnTo()
|
||||||
),
|
|
||||||
[displayPath, sessionReturnTo]
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
}, [displayPath])
|
||||||
|
|
||||||
const { data: sharesData } = useDriveShares(displayPath, Boolean(displayPath))
|
const { data: sharesData } = useDriveShares(displayPath, Boolean(displayPath))
|
||||||
const { rename } = useDriveMutations()
|
const { rename } = useDriveMutations()
|
||||||
|
|||||||
@ -58,6 +58,8 @@ export type DocsFileMenuActions = {
|
|||||||
onSecurityLimits: () => void
|
onSecurityLimits: () => void
|
||||||
onPageSetup: () => void
|
onPageSetup: () => void
|
||||||
onPrint: () => void
|
onPrint: () => void
|
||||||
|
/** Dev-only: purge .ultidoc.json sidecar and force DOCX reimport. */
|
||||||
|
onPurgeSidecarAndReimport?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function MenuIcon({ children }: { children: ReactNode }) {
|
function MenuIcon({ children }: { children: ReactNode }) {
|
||||||
@ -282,6 +284,22 @@ export function DocsFileMenu({
|
|||||||
Configuration de la page
|
Configuration de la page
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
|
|
||||||
|
{actions.onPurgeSidecarAndReimport ? (
|
||||||
|
<>
|
||||||
|
<MenubarSeparator />
|
||||||
|
<MenubarItem
|
||||||
|
className={cn("docs-menu-item", "text-amber-700 focus:text-amber-800 dark:text-amber-400")}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={actions.onPurgeSidecarAndReimport}
|
||||||
|
>
|
||||||
|
<MenuIcon>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Purger sidecar et réimporter doc
|
||||||
|
</MenubarItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onPrint}>
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onPrint}>
|
||||||
<MenuIcon>
|
<MenuIcon>
|
||||||
<Printer className="size-4" />
|
<Printer className="size-4" />
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -9,79 +10,292 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import {
|
import {
|
||||||
DRIVE_BTN_GHOST,
|
DRIVE_BTN_GHOST,
|
||||||
DRIVE_BTN_PRIMARY,
|
DRIVE_BTN_PRIMARY,
|
||||||
DRIVE_DIALOG_BODY,
|
|
||||||
DRIVE_DIALOG_CONTENT,
|
DRIVE_DIALOG_CONTENT,
|
||||||
DRIVE_DIALOG_FOOTER,
|
DRIVE_DIALOG_FOOTER,
|
||||||
DRIVE_DIALOG_HEADER,
|
|
||||||
DRIVE_DIALOG_OVERLAY,
|
DRIVE_DIALOG_OVERLAY,
|
||||||
DRIVE_TEXT_SECONDARY,
|
DRIVE_TEXT_SECONDARY,
|
||||||
DRIVE_TEXT_TITLE,
|
DRIVE_TEXT_TITLE,
|
||||||
} from "@/lib/drive/drive-dialog-styles"
|
} from "@/lib/drive/drive-dialog-styles"
|
||||||
|
import {
|
||||||
|
buildPageSetupFromDraft,
|
||||||
|
draftFromPageSetup,
|
||||||
|
formatPaperSizeLabel,
|
||||||
|
type DocPageSetup,
|
||||||
|
type PageSetupDraft,
|
||||||
|
} from "@/lib/drive/doc-page-setup"
|
||||||
|
import {
|
||||||
|
pageSetupDraftsEqual,
|
||||||
|
readUserPageSetupDefaults,
|
||||||
|
saveUserPageSetupDefaults,
|
||||||
|
} from "@/lib/drive/docs-page-defaults"
|
||||||
import { PAGE_FORMATS, type PageFormatId } from "@/lib/drive/page-formats"
|
import { PAGE_FORMATS, type PageFormatId } from "@/lib/drive/page-formats"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type MarginSide = keyof PageSetupDraft["marginsCm"]
|
||||||
|
|
||||||
|
const MARGIN_FIELDS: { key: MarginSide; label: string }[] = [
|
||||||
|
{ key: "top", label: "Haut" },
|
||||||
|
{ key: "bottom", label: "Bas" },
|
||||||
|
{ key: "left", label: "Gauche" },
|
||||||
|
{ key: "right", label: "Droite" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const FIELD_LABEL = "text-xs font-medium text-muted-foreground"
|
||||||
|
const FIELD_CONTROL = "h-9"
|
||||||
|
|
||||||
|
function parseMarginInput(raw: string): number {
|
||||||
|
const normalized = raw.replace(",", ".").trim()
|
||||||
|
if (!normalized) return 0
|
||||||
|
const value = Number.parseFloat(normalized)
|
||||||
|
return Number.isFinite(value) ? value : 0
|
||||||
|
}
|
||||||
|
|
||||||
export function DocsPageSetupDialog({
|
export function DocsPageSetupDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
pageFormatId,
|
pageSetup,
|
||||||
onPageFormatChange,
|
fallbackFormatId,
|
||||||
|
onApply,
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
pageFormatId: PageFormatId
|
pageSetup: DocPageSetup | null
|
||||||
onPageFormatChange: (id: PageFormatId) => void
|
fallbackFormatId: PageFormatId
|
||||||
|
onApply: (setup: DocPageSetup) => void
|
||||||
}) {
|
}) {
|
||||||
|
const [draft, setDraft] = useState<PageSetupDraft>(() =>
|
||||||
|
draftFromPageSetup(pageSetup, fallbackFormatId)
|
||||||
|
)
|
||||||
|
const [savedDefaults, setSavedDefaults] = useState<PageSetupDraft>(() =>
|
||||||
|
readUserPageSetupDefaults(fallbackFormatId)
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setDraft(draftFromPageSetup(pageSetup, fallbackFormatId))
|
||||||
|
setSavedDefaults(readUserPageSetupDefaults(fallbackFormatId))
|
||||||
|
}
|
||||||
|
}, [open, pageSetup, fallbackFormatId])
|
||||||
|
|
||||||
|
const matchesSavedDefaults = useMemo(
|
||||||
|
() => pageSetupDraftsEqual(draft, savedDefaults),
|
||||||
|
[draft, savedDefaults]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
onApply(buildPageSetupFromDraft(draft, pageSetup))
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveDefaults = () => {
|
||||||
|
saveUserPageSetupDefaults(draft)
|
||||||
|
setSavedDefaults({ ...draft })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateMargin = (key: MarginSide, raw: string) => {
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
marginsCm: { ...prev.marginsCm, [key]: parseMarginInput(raw) },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
overlayClassName={DRIVE_DIALOG_OVERLAY}
|
overlayClassName={DRIVE_DIALOG_OVERLAY}
|
||||||
className={cn(DRIVE_DIALOG_CONTENT, "sm:max-w-[420px]")}
|
className={cn(
|
||||||
|
DRIVE_DIALOG_CONTENT,
|
||||||
|
"h-auto max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] gap-0 overflow-y-auto p-0 sm:max-w-[480px]"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<DialogHeader className={DRIVE_DIALOG_HEADER}>
|
<DialogHeader className="space-y-0 px-5 py-3 text-left">
|
||||||
<DialogTitle className={cn("text-base font-medium", DRIVE_TEXT_TITLE)}>
|
<DialogTitle className={cn("text-lg font-normal", DRIVE_TEXT_TITLE)}>
|
||||||
Configuration de la page
|
Configuration de la page
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className={cn("text-sm", DRIVE_TEXT_SECONDARY)}>
|
<DialogDescription className="sr-only">
|
||||||
Format de page utilisé pour l'affichage et l'impression.
|
Format, orientation, couleur et marges du document.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className={cn(DRIVE_DIALOG_BODY, "space-y-1")}>
|
|
||||||
{PAGE_FORMATS.map((format) => (
|
<Tabs defaultValue="pages" className="gap-0">
|
||||||
<button
|
<div className="px-5">
|
||||||
key={format.id}
|
<TabsList className="h-9 w-full gap-0 rounded-lg bg-[#f1f3f4] p-1 dark:bg-muted">
|
||||||
type="button"
|
<TabsTrigger
|
||||||
className={cn(
|
value="pages"
|
||||||
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm hover:bg-accent",
|
className="flex-1 rounded-md px-4 text-sm font-medium text-muted-foreground shadow-none data-[state=active]:bg-white data-[state=active]:text-[#1a73e8] data-[state=active]:shadow-sm dark:data-[state=active]:bg-background dark:data-[state=active]:text-[#8ab4f8]"
|
||||||
pageFormatId === format.id && "bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => onPageFormatChange(format.id)}
|
|
||||||
>
|
>
|
||||||
<span>{format.label}</span>
|
Pages
|
||||||
<span className="text-xs text-muted-foreground">
|
</TabsTrigger>
|
||||||
{format.widthMm} × {format.heightMm} mm
|
<TabsTrigger
|
||||||
</span>
|
value="pageless"
|
||||||
</button>
|
disabled
|
||||||
|
className="flex-1 rounded-md px-4 text-sm font-medium text-muted-foreground/50 shadow-none"
|
||||||
|
>
|
||||||
|
Sans pages
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="pages" className="mt-0 px-5 py-3">
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||||
|
<div className="col-span-2 space-y-1">
|
||||||
|
<Label className={FIELD_LABEL}>Appliquer à</Label>
|
||||||
|
<Select value="document" disabled>
|
||||||
|
<SelectTrigger className={FIELD_CONTROL}>
|
||||||
|
<SelectValue>Au document entier</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="document">Au document entier</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 space-y-1">
|
||||||
|
<Label className={FIELD_LABEL}>Orientation</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={draft.orientation}
|
||||||
|
onValueChange={(value: "portrait" | "landscape") =>
|
||||||
|
setDraft((prev) => ({ ...prev, orientation: value }))
|
||||||
|
}
|
||||||
|
className="flex h-9 items-center gap-5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RadioGroupItem value="portrait" id="docs-page-orientation-portrait" />
|
||||||
|
<Label htmlFor="docs-page-orientation-portrait" className="text-sm font-normal">
|
||||||
|
Portrait
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RadioGroupItem value="landscape" id="docs-page-orientation-landscape" />
|
||||||
|
<Label htmlFor="docs-page-orientation-landscape" className="text-sm font-normal">
|
||||||
|
Paysage
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className={FIELD_LABEL}>Format de papier</Label>
|
||||||
|
<Select
|
||||||
|
value={draft.formatId}
|
||||||
|
onValueChange={(value: PageFormatId) =>
|
||||||
|
setDraft((prev) => ({ ...prev, formatId: value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={FIELD_CONTROL}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PAGE_FORMATS.map((format) => (
|
||||||
|
<SelectItem key={format.id} value={format.id}>
|
||||||
|
{formatPaperSizeLabel(format)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className={FIELD_LABEL}>Couleur de la page</Label>
|
||||||
|
<div className="flex h-9 items-center">
|
||||||
|
<label className="relative inline-flex h-8 w-8 cursor-pointer items-center justify-center">
|
||||||
|
<span
|
||||||
|
className="block h-7 w-7 rounded-full border border-[#dadce0] dark:border-border"
|
||||||
|
style={{ backgroundColor: draft.pageColor }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={draft.pageColor}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((prev) => ({ ...prev, pageColor: event.target.value }))
|
||||||
|
}
|
||||||
|
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||||
|
aria-label="Couleur de la page"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 space-y-1">
|
||||||
|
<Label className={FIELD_LABEL}>Marges (centimètres)</Label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{MARGIN_FIELDS.map(({ key, label }) => (
|
||||||
|
<div key={key} className="space-y-1">
|
||||||
|
<Label
|
||||||
|
htmlFor={`docs-page-margin-${key}`}
|
||||||
|
className="text-[11px] font-normal text-muted-foreground"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`docs-page-margin-${key}`}
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
className={cn(FIELD_CONTROL, "px-2 text-center text-sm")}
|
||||||
|
value={String(draft.marginsCm[key])}
|
||||||
|
onChange={(event) => updateMargin(key, event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className={DRIVE_DIALOG_FOOTER}>
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<DialogFooter
|
||||||
|
className={cn(
|
||||||
|
DRIVE_DIALOG_FOOTER,
|
||||||
|
"flex flex-wrap items-center justify-end gap-2 px-5 py-3 sm:justify-end"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{matchesSavedDefaults ? (
|
||||||
|
<span className={cn("order-1 text-sm", DRIVE_TEXT_SECONDARY)}>
|
||||||
|
Valeurs par défaut enregistrées
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={DRIVE_BTN_PRIMARY}
|
variant="link"
|
||||||
onClick={() => onOpenChange(false)}
|
className="order-1 h-8 px-0 text-sm font-normal text-[#1a73e8] hover:text-[#174ea6]"
|
||||||
|
onClick={handleSaveDefaults}
|
||||||
>
|
>
|
||||||
OK
|
Enregistrer comme valeurs par défaut
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={DRIVE_BTN_GHOST}
|
className={cn(
|
||||||
|
DRIVE_BTN_GHOST,
|
||||||
|
"order-2 h-8 rounded-full px-4 text-[#1a73e8] hover:bg-transparent hover:text-[#174ea6]"
|
||||||
|
)}
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className={cn(DRIVE_BTN_PRIMARY, "order-3 h-8 rounded-full px-5")}
|
||||||
|
onClick={handleApply}
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@ -2,13 +2,7 @@
|
|||||||
|
|
||||||
import { memo, useEffect, useRef, useState } from "react"
|
import { memo, useEffect, useRef, useState } from "react"
|
||||||
import { EditorContent, type Editor } from "@tiptap/react"
|
import { EditorContent, type Editor } from "@tiptap/react"
|
||||||
import {
|
import type { DocPageLayout } from "@/lib/drive/doc-page-setup"
|
||||||
getPageFormat,
|
|
||||||
PAGE_MARGIN_PX,
|
|
||||||
pageFormatHeightPx,
|
|
||||||
pageFormatWidthPx,
|
|
||||||
type PageFormatId,
|
|
||||||
} from "@/lib/drive/page-formats"
|
|
||||||
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"
|
||||||
|
|
||||||
@ -29,20 +23,20 @@ function measureProseContentHeight(prose: HTMLElement): number {
|
|||||||
|
|
||||||
function DocsPageViewInner({
|
function DocsPageViewInner({
|
||||||
editor,
|
editor,
|
||||||
pageFormatId,
|
pageLayout,
|
||||||
zoom,
|
zoom,
|
||||||
editable,
|
editable,
|
||||||
onPageCountChange,
|
onPageCountChange,
|
||||||
}: {
|
}: {
|
||||||
editor: Editor
|
editor: Editor
|
||||||
pageFormatId: PageFormatId
|
pageLayout: DocPageLayout
|
||||||
zoom: number
|
zoom: number
|
||||||
editable: boolean
|
editable: boolean
|
||||||
onPageCountChange?: (count: number) => void
|
onPageCountChange?: (count: number) => void
|
||||||
}) {
|
}) {
|
||||||
const format = getPageFormat(pageFormatId)
|
const pageWidth = pageLayout.widthPx
|
||||||
const pageWidth = pageFormatWidthPx(format)
|
const pageHeight = pageLayout.heightPx
|
||||||
const pageHeight = pageFormatHeightPx(format)
|
const margins = pageLayout.marginsPx
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const contentRef = useRef<HTMLDivElement>(null)
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
const [pageCount, setPageCount] = useState(1)
|
const [pageCount, setPageCount] = useState(1)
|
||||||
@ -78,13 +72,9 @@ function DocsPageViewInner({
|
|||||||
if (!prose) return
|
if (!prose) return
|
||||||
|
|
||||||
const contentHeight = measureProseContentHeight(prose)
|
const contentHeight = measureProseContentHeight(prose)
|
||||||
const paddedHeight = PAGE_MARGIN_PX * 2 + contentHeight
|
const paddedHeight = margins.top + margins.bottom + contentHeight
|
||||||
const count = Math.max(1, Math.ceil(paddedHeight / pageHeight))
|
const count = Math.max(1, Math.ceil(paddedHeight / pageHeight))
|
||||||
setPageCount((prev) => {
|
setPageCount((prev) => (prev === count ? prev : count))
|
||||||
if (prev === count) return prev
|
|
||||||
onPageCountChangeRef.current?.(count)
|
|
||||||
return count
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleMeasure = () => {
|
const scheduleMeasure = () => {
|
||||||
@ -105,12 +95,71 @@ function DocsPageViewInner({
|
|||||||
ro?.disconnect()
|
ro?.disconnect()
|
||||||
editor.off("transaction", onTransaction)
|
editor.off("transaction", onTransaction)
|
||||||
}
|
}
|
||||||
}, [pageHeight, editor])
|
}, [margins.bottom, margins.top, pageHeight, editor])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onPageCountChangeRef.current?.(pageCount)
|
||||||
|
}, [pageCount])
|
||||||
|
|
||||||
const stackHeight = pageCount * pageHeight + (pageCount - 1) * PAGE_GAP_PX
|
const stackHeight = pageCount * pageHeight + (pageCount - 1) * PAGE_GAP_PX
|
||||||
const proseMinHeight = stackHeight - PAGE_MARGIN_PX * 2
|
const innerMinHeight = Math.max(pageHeight - margins.top - margins.bottom, stackHeight - margins.top - margins.bottom)
|
||||||
const scaledHeight = stackHeight * scale
|
const scaledHeight = stackHeight * scale
|
||||||
const verticalPadding = narrowViewport ? 32 : 64
|
const verticalPadding = narrowViewport ? 32 : 64
|
||||||
|
const textAreaBorderCss = pageLayout.textAreaBorderCss
|
||||||
|
const sheetBorderCss = pageLayout.sheetBorderCss
|
||||||
|
const pageBackground = pageLayout.pageColor
|
||||||
|
const backgroundLayers = pageLayout.pageBackgroundLayers
|
||||||
|
|
||||||
|
const renderPageBackground = (index: number) => (
|
||||||
|
<>
|
||||||
|
{backgroundLayers?.gradientCss ? (
|
||||||
|
<div
|
||||||
|
key={`bg-gradient-${index}`}
|
||||||
|
className="pointer-events-none absolute inset-0 z-[1]"
|
||||||
|
style={{ background: backgroundLayers.gradientCss }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{backgroundLayers?.fillImageStyle ? (
|
||||||
|
<div
|
||||||
|
key={`bg-image-${index}`}
|
||||||
|
className="pointer-events-none absolute inset-0 z-[1]"
|
||||||
|
style={backgroundLayers.fillImageStyle}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{backgroundLayers?.watermarkStyle ? (
|
||||||
|
<div
|
||||||
|
key={`bg-watermark-${index}`}
|
||||||
|
className="pointer-events-none absolute inset-0 z-[2] flex items-center justify-center overflow-hidden"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{backgroundLayers.watermarkStyle.imageSrc ? (
|
||||||
|
<img
|
||||||
|
src={backgroundLayers.watermarkStyle.imageSrc}
|
||||||
|
alt=""
|
||||||
|
className="max-h-[70%] max-w-[70%] select-none object-contain"
|
||||||
|
style={{
|
||||||
|
opacity: backgroundLayers.watermarkStyle.opacity,
|
||||||
|
transform: `rotate(${backgroundLayers.watermarkStyle.rotationDeg}deg)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="select-none whitespace-nowrap text-[72px] font-light leading-none"
|
||||||
|
style={{
|
||||||
|
color: backgroundLayers.watermarkStyle.color,
|
||||||
|
opacity: backgroundLayers.watermarkStyle.opacity,
|
||||||
|
transform: `rotate(${backgroundLayers.watermarkStyle.rotationDeg}deg)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{backgroundLayers.watermarkStyle.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -142,28 +191,59 @@ function DocsPageViewInner({
|
|||||||
{Array.from({ length: pageCount }, (_, index) => (
|
{Array.from({ length: pageCount }, (_, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="ultidrive-docs-page absolute left-0 bg-white dark:bg-white"
|
className={cn(
|
||||||
|
"ultidrive-docs-page absolute left-0 overflow-hidden dark:bg-white",
|
||||||
|
sheetBorderCss && "ultidrive-docs-page--imported-border"
|
||||||
|
)}
|
||||||
style={{
|
style={{
|
||||||
top: index * (pageHeight + PAGE_GAP_PX),
|
top: index * (pageHeight + PAGE_GAP_PX),
|
||||||
width: pageWidth,
|
width: pageWidth,
|
||||||
height: pageHeight,
|
height: pageHeight,
|
||||||
|
backgroundColor: pageBackground,
|
||||||
boxShadow:
|
boxShadow:
|
||||||
"0 1px 3px 1px rgba(60,64,67,.15), 0 1px 2px 0 rgba(60,64,67,.3)",
|
"0 1px 3px 1px rgba(60,64,67,.15), 0 1px 2px 0 rgba(60,64,67,.3)",
|
||||||
|
...(sheetBorderCss
|
||||||
|
? {
|
||||||
|
borderTop: sheetBorderCss.top ?? "none",
|
||||||
|
borderRight: sheetBorderCss.right ?? "none",
|
||||||
|
borderBottom: sheetBorderCss.bottom ?? "none",
|
||||||
|
borderLeft: sheetBorderCss.left ?? "none",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{renderPageBackground(index)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{textAreaBorderCss
|
||||||
|
? Array.from({ length: pageCount }, (_, index) => (
|
||||||
|
<div
|
||||||
|
key={`text-border-${index}`}
|
||||||
|
className="pointer-events-none absolute z-[5] box-border"
|
||||||
|
style={{
|
||||||
|
top: index * (pageHeight + PAGE_GAP_PX) + margins.top,
|
||||||
|
left: margins.left,
|
||||||
|
width: pageWidth - margins.left - margins.right,
|
||||||
|
height: pageHeight - margins.top - margins.bottom,
|
||||||
|
borderTop: textAreaBorderCss.top ?? "none",
|
||||||
|
borderRight: textAreaBorderCss.right ?? "none",
|
||||||
|
borderBottom: textAreaBorderCss.bottom ?? "none",
|
||||||
|
borderLeft: textAreaBorderCss.left ?? "none",
|
||||||
}}
|
}}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
))}
|
))
|
||||||
|
: null}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className="ultidrive-docs-editor-surface relative z-10"
|
className="ultidrive-docs-editor-surface relative z-10"
|
||||||
style={{
|
style={{
|
||||||
padding: PAGE_MARGIN_PX,
|
padding: `${margins.top}px ${margins.right}px ${margins.bottom}px ${margins.left}px`,
|
||||||
minHeight: stackHeight,
|
minHeight: stackHeight,
|
||||||
["--docs-prose-min-height" as string]: `${Math.max(
|
["--docs-prose-min-height" as string]: `${innerMinHeight}px`,
|
||||||
pageHeight - PAGE_MARGIN_PX * 2,
|
|
||||||
proseMinHeight
|
|
||||||
)}px`,
|
|
||||||
}}
|
}}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (!editable) return
|
if (!editable) return
|
||||||
@ -188,16 +268,14 @@ function DocsPageViewInner({
|
|||||||
export const DocsPageView = memo(DocsPageViewInner)
|
export const DocsPageView = memo(DocsPageViewInner)
|
||||||
|
|
||||||
export function DocsStatusBar({
|
export function DocsStatusBar({
|
||||||
pageFormatId,
|
pageLayout,
|
||||||
pageCount,
|
pageCount,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
pageFormatId: PageFormatId
|
pageLayout: DocPageLayout
|
||||||
pageCount: number
|
pageCount: number
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const format = getPageFormat(pageFormatId)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -207,7 +285,7 @@ export function DocsStatusBar({
|
|||||||
>
|
>
|
||||||
<span>Page 1 sur {pageCount}</span>
|
<span>Page 1 sur {pageCount}</span>
|
||||||
<span>
|
<span>
|
||||||
{format.label} ({format.widthMm} × {format.heightMm} mm)
|
{pageLayout.format.label} ({pageLayout.format.widthMm} × {pageLayout.format.heightMm} mm)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -7,7 +7,7 @@ function trimSlash(url: string) {
|
|||||||
/** Public app origin for OAuth redirects and post-login navigation (never 0.0.0.0). */
|
/** Public app origin for OAuth redirects and post-login navigation (never 0.0.0.0). */
|
||||||
export function getAppOrigin(): string {
|
export function getAppOrigin(): string {
|
||||||
const raw = (
|
const raw = (
|
||||||
process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"
|
process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3004"
|
||||||
).replace(/\/$/, "")
|
).replace(/\/$/, "")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -17,7 +17,7 @@ export function getAppOrigin(): string {
|
|||||||
}
|
}
|
||||||
return url.origin
|
return url.origin
|
||||||
} catch {
|
} catch {
|
||||||
return "http://localhost:3000"
|
return "http://localhost:3004"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
73
lib/drive/doc-page-background.test.ts
Normal file
73
lib/drive/doc-page-background.test.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
import {
|
||||||
|
extractDocxPageBackground,
|
||||||
|
parseDocumentBackgroundXml,
|
||||||
|
parseWatermarkFromHeaderXml,
|
||||||
|
resolvePageBackgroundLayers,
|
||||||
|
} from "./doc-page-background.ts"
|
||||||
|
|
||||||
|
describe("doc-page-background", () => {
|
||||||
|
it("parses solid page color from w:background", () => {
|
||||||
|
const result = parseDocumentBackgroundXml(
|
||||||
|
`<w:background w:color="C6D9F1"><v:background fillcolor="#c6d9f1"/></w:background>`,
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
assert.equal(result.pageColor, "#C6D9F1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("parses gradient fill css", () => {
|
||||||
|
const result = parseDocumentBackgroundXml(
|
||||||
|
`<w:background w:color="FFFFFF">
|
||||||
|
<v:background>
|
||||||
|
<v:fill type="gradient" color="#c0504d" color2="#f0504d" angle="45"/>
|
||||||
|
</v:background>
|
||||||
|
</w:background>`,
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
assert.match(result.background?.gradientCss ?? "", /linear-gradient\(45deg/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("parses text watermark from header vml", () => {
|
||||||
|
const watermark = parseWatermarkFromHeaderXml(
|
||||||
|
`<w:hdr>
|
||||||
|
<w:p><w:r><w:pict>
|
||||||
|
<v:shape style="rotation:315" fillcolor="#d9d9d9">
|
||||||
|
<v:fill opacity=".35"/>
|
||||||
|
<v:textpath string="CONFIDENTIEL"/>
|
||||||
|
</v:shape>
|
||||||
|
</w:pict></w:r></w:p>
|
||||||
|
</w:hdr>`,
|
||||||
|
{},
|
||||||
|
"word/_rels/header1.xml.rels"
|
||||||
|
)
|
||||||
|
assert.equal(watermark?.kind, "text")
|
||||||
|
assert.equal(watermark?.text, "CONFIDENTIEL")
|
||||||
|
assert.equal(watermark?.rotationDeg, 315)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("resolves background layers for rendering", () => {
|
||||||
|
const layers = resolvePageBackgroundLayers({
|
||||||
|
gradientCss: "linear-gradient(180deg, #fff, #eee)",
|
||||||
|
watermark: {
|
||||||
|
kind: "text",
|
||||||
|
text: "BROUILLON",
|
||||||
|
color: "#cccccc",
|
||||||
|
opacity: 0.4,
|
||||||
|
rotationDeg: -45,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.ok(layers.gradientCss)
|
||||||
|
assert.equal(layers.watermarkStyle?.text, "BROUILLON")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("extracts background from document xml archive", () => {
|
||||||
|
const archive = {
|
||||||
|
"word/document.xml": new TextEncoder().encode(
|
||||||
|
`<w:document><w:background w:color="FFF2CC"/></w:document>`
|
||||||
|
),
|
||||||
|
}
|
||||||
|
const result = extractDocxPageBackground(archive, new TextDecoder().decode(archive["word/document.xml"]))
|
||||||
|
assert.equal(result.pageColor, "#FFF2CC")
|
||||||
|
})
|
||||||
|
})
|
||||||
314
lib/drive/doc-page-background.ts
Normal file
314
lib/drive/doc-page-background.ts
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
type DocxArchive = Record<string, Uint8Array>
|
||||||
|
|
||||||
|
function parseWordColor(raw: string | undefined): string {
|
||||||
|
if (!raw || raw.toLowerCase() === "auto") return "#000000"
|
||||||
|
if (raw.startsWith("#")) return raw
|
||||||
|
if (/^[0-9a-f]{6}$/i.test(raw)) return `#${raw}`
|
||||||
|
return "#000000"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocPageFillImage = {
|
||||||
|
src: string
|
||||||
|
mode: "tile" | "frame" | "cover"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocPageWatermark = {
|
||||||
|
kind: "text" | "image"
|
||||||
|
text?: string
|
||||||
|
src?: string
|
||||||
|
color?: string
|
||||||
|
opacity?: number
|
||||||
|
rotationDeg?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocPageBackground = {
|
||||||
|
fillImage?: DocPageFillImage | null
|
||||||
|
watermark?: DocPageWatermark | null
|
||||||
|
gradientCss?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocPageBackgroundLayers = {
|
||||||
|
fillImageStyle?: {
|
||||||
|
backgroundImage: string
|
||||||
|
backgroundSize: string
|
||||||
|
backgroundRepeat: string
|
||||||
|
backgroundPosition: string
|
||||||
|
}
|
||||||
|
watermarkStyle?: {
|
||||||
|
text?: string
|
||||||
|
imageSrc?: string
|
||||||
|
color: string
|
||||||
|
opacity: number
|
||||||
|
rotationDeg: number
|
||||||
|
}
|
||||||
|
gradientCss?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeXml(bytes: Uint8Array | undefined): string {
|
||||||
|
if (!bytes) return ""
|
||||||
|
return new TextDecoder().decode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDocxRelationships(relsXml: string): Map<string, string> {
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
for (const match of relsXml.matchAll(/<Relationship\b[^>]*\/?>/gi)) {
|
||||||
|
const id = match[0].match(/\bId="([^"]+)"/i)?.[1]
|
||||||
|
const target = match[0].match(/\bTarget="([^"]+)"/i)?.[1]
|
||||||
|
if (id && target) map.set(id, target)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
function mimeFromMediaPath(path: string): string {
|
||||||
|
const ext = path.split(".").pop()?.toLowerCase()
|
||||||
|
switch (ext) {
|
||||||
|
case "png":
|
||||||
|
return "image/png"
|
||||||
|
case "jpg":
|
||||||
|
case "jpeg":
|
||||||
|
return "image/jpeg"
|
||||||
|
case "gif":
|
||||||
|
return "image/gif"
|
||||||
|
case "webp":
|
||||||
|
return "image/webp"
|
||||||
|
case "bmp":
|
||||||
|
return "image/bmp"
|
||||||
|
default:
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uint8ArrayToDataUrl(bytes: Uint8Array, mime: string): string {
|
||||||
|
let binary = ""
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]!)
|
||||||
|
}
|
||||||
|
const base64 = typeof btoa === "function" ? btoa(binary) : Buffer.from(bytes).toString("base64")
|
||||||
|
return `data:${mime};base64,${base64}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRelationshipTarget(baseDir: string, target: string): string {
|
||||||
|
if (target.startsWith("/")) return target.slice(1)
|
||||||
|
const baseParts = baseDir.split("/").filter(Boolean)
|
||||||
|
for (const part of target.split("/")) {
|
||||||
|
if (part === "..") baseParts.pop()
|
||||||
|
else if (part !== ".") baseParts.push(part)
|
||||||
|
}
|
||||||
|
return baseParts.join("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDocxMediaDataUrl(
|
||||||
|
archive: DocxArchive,
|
||||||
|
relsPath: string,
|
||||||
|
rId: string
|
||||||
|
): string | null {
|
||||||
|
const relsXml = decodeXml(archive[relsPath])
|
||||||
|
if (!relsXml) return null
|
||||||
|
const target = parseDocxRelationships(relsXml).get(rId)
|
||||||
|
if (!target) return null
|
||||||
|
const baseDir = relsPath.replace(/\/_rels\/[^/]+\.rels$/, "")
|
||||||
|
const mediaPath = resolveRelationshipTarget(baseDir, target)
|
||||||
|
const bytes = archive[mediaPath]
|
||||||
|
if (!bytes) return null
|
||||||
|
return uint8ArrayToDataUrl(bytes, mimeFromMediaPath(mediaPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVmlColor(raw: string | undefined): string | undefined {
|
||||||
|
if (!raw) return undefined
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
if (!trimmed) return undefined
|
||||||
|
if (trimmed.startsWith("#")) return trimmed.length === 7 ? trimmed : undefined
|
||||||
|
if (/^[0-9a-f]{6}$/i.test(trimmed)) return `#${trimmed}`
|
||||||
|
return parseWordColor(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFillImageMode(fillTag: string): DocPageFillImage["mode"] {
|
||||||
|
const type = fillTag.match(/\btype="([^"]+)"/i)?.[1]?.toLowerCase()
|
||||||
|
if (type === "tile") return "tile"
|
||||||
|
if (type === "frame") return "frame"
|
||||||
|
return "cover"
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGradientCss(fillTag: string): string | null {
|
||||||
|
const type = fillTag.match(/\btype="([^"]+)"/i)?.[1]?.toLowerCase()
|
||||||
|
const color = parseVmlColor(fillTag.match(/\bcolor="([^"]+)"/i)?.[1])
|
||||||
|
const color2 = parseVmlColor(fillTag.match(/\bcolor2="([^"]+)"/i)?.[1])
|
||||||
|
if (!color || !color2) return null
|
||||||
|
const angleRaw = fillTag.match(/\bangle="([^"]+)"/i)?.[1]
|
||||||
|
const angle = angleRaw ? Number.parseFloat(angleRaw) : 0
|
||||||
|
if (type === "gradientradial") {
|
||||||
|
return `radial-gradient(circle, ${color}, ${color2})`
|
||||||
|
}
|
||||||
|
if (type === "gradient") {
|
||||||
|
return `linear-gradient(${Number.isFinite(angle) ? angle : 0}deg, ${color}, ${color2})`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDocumentBackgroundXml(
|
||||||
|
documentXml: string,
|
||||||
|
archive: DocxArchive
|
||||||
|
): { pageColor?: string; background?: DocPageBackground | null } {
|
||||||
|
const block =
|
||||||
|
documentXml.match(/<w:background\b[^>]*>[\s\S]*?<\/w:background>/i)?.[0] ??
|
||||||
|
documentXml.match(/<w:background\b[^>]*\/>/i)?.[0]
|
||||||
|
if (!block) return {}
|
||||||
|
|
||||||
|
const pageColor = parseWordColor(block.match(/\bw:color="([^"]+)"/i)?.[1])
|
||||||
|
const fillColor = parseVmlColor(block.match(/\bfillcolor="([^"]+)"/i)?.[1])
|
||||||
|
const resolvedColor =
|
||||||
|
pageColor !== "#000000" || block.includes('w:color="')
|
||||||
|
? pageColor
|
||||||
|
: fillColor ?? pageColor
|
||||||
|
|
||||||
|
const fillTag = block.match(/<v:fill\b[^>]*\/?>/i)?.[0]
|
||||||
|
let background: DocPageBackground | null = null
|
||||||
|
|
||||||
|
if (fillTag) {
|
||||||
|
const gradientCss = parseGradientCss(fillTag)
|
||||||
|
if (gradientCss) {
|
||||||
|
background = { gradientCss }
|
||||||
|
}
|
||||||
|
const rId = fillTag.match(/\br:id="([^"]+)"/i)?.[1]
|
||||||
|
if (rId) {
|
||||||
|
const src = resolveDocxMediaDataUrl(archive, "word/_rels/document.xml.rels", rId)
|
||||||
|
if (src) {
|
||||||
|
background = {
|
||||||
|
...(background ?? {}),
|
||||||
|
fillImage: { src, mode: parseFillImageMode(fillTag) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageColor: resolvedColor !== "#000000" || block.includes('w:color="') ? resolvedColor : undefined,
|
||||||
|
background,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRotationDeg(style: string | undefined): number {
|
||||||
|
if (!style) return -35
|
||||||
|
const rotation = style.match(/(?:rotation|rotate):\s*(-?\d+(?:\.\d+)?)/i)?.[1]
|
||||||
|
if (!rotation) return -35
|
||||||
|
const value = Number.parseFloat(rotation)
|
||||||
|
return Number.isFinite(value) ? value : -35
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOpacity(raw: string | undefined): number {
|
||||||
|
if (!raw) return 0.35
|
||||||
|
const value = Number.parseFloat(raw)
|
||||||
|
if (!Number.isFinite(value)) return 0.35
|
||||||
|
return Math.min(1, Math.max(0, value > 1 ? value / 100 : value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWatermarkFromHeaderXml(
|
||||||
|
headerXml: string,
|
||||||
|
archive: DocxArchive,
|
||||||
|
relsPath: string
|
||||||
|
): DocPageWatermark | null {
|
||||||
|
const shape = headerXml.match(/<v:shape\b[^>]*>[\s\S]*?<\/v:shape>/i)?.[0]
|
||||||
|
if (!shape) return null
|
||||||
|
|
||||||
|
const text =
|
||||||
|
shape.match(/<v:textpath\b[^>]*\bw:string="([^"]+)"/i)?.[1] ??
|
||||||
|
shape.match(/<v:textpath\b[^>]*\bstring="([^"]+)"/i)?.[1]
|
||||||
|
if (text && text.trim()) {
|
||||||
|
const style = shape.match(/\bstyle="([^"]+)"/i)?.[1]
|
||||||
|
const fill = shape.match(/<v:fill\b[^>]*\/?>/i)?.[0]
|
||||||
|
const color =
|
||||||
|
parseVmlColor(shape.match(/\bfillcolor="([^"]+)"/i)?.[1]) ??
|
||||||
|
parseVmlColor(fill?.match(/\bcolor="([^"]+)"/i)?.[1]) ??
|
||||||
|
"#b4b4b4"
|
||||||
|
return {
|
||||||
|
kind: "text",
|
||||||
|
text: text.trim(),
|
||||||
|
color,
|
||||||
|
opacity: parseOpacity(fill?.match(/\bopacity="([^"]+)"/i)?.[1]),
|
||||||
|
rotationDeg: parseRotationDeg(style),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagedata = shape.match(/<v:imagedata\b[^>]*\/?>/i)?.[0]
|
||||||
|
const rId = imagedata?.match(/\br:id="([^"]+)"/i)?.[1]
|
||||||
|
if (rId) {
|
||||||
|
const src = resolveDocxMediaDataUrl(archive, relsPath, rId)
|
||||||
|
if (src) {
|
||||||
|
return {
|
||||||
|
kind: "image",
|
||||||
|
src,
|
||||||
|
opacity: parseOpacity(shape.match(/<v:fill\b[^>]*\bopacity="([^"]+)"/i)?.[1]),
|
||||||
|
rotationDeg: parseRotationDeg(shape.match(/\bstyle="([^"]+)"/i)?.[1]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractDocxPageBackground(
|
||||||
|
archive: DocxArchive,
|
||||||
|
documentXml: string
|
||||||
|
): { pageColor?: string; background?: DocPageBackground | null } {
|
||||||
|
const fromDocument = parseDocumentBackgroundXml(documentXml, archive)
|
||||||
|
let watermark: DocPageWatermark | null = null
|
||||||
|
|
||||||
|
for (const key of Object.keys(archive)) {
|
||||||
|
const headerMatch = key.match(/^word\/(header\d+)\.xml$/)
|
||||||
|
if (!headerMatch) continue
|
||||||
|
const headerName = headerMatch[1]!
|
||||||
|
const relsPath = `word/_rels/${headerName}.xml.rels`
|
||||||
|
const parsed = parseWatermarkFromHeaderXml(decodeXml(archive[key]), archive, relsPath)
|
||||||
|
if (parsed) {
|
||||||
|
watermark = parsed
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const background: DocPageBackground | null =
|
||||||
|
fromDocument.background || watermark
|
||||||
|
? {
|
||||||
|
...(fromDocument.background ?? {}),
|
||||||
|
...(watermark ? { watermark } : {}),
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageColor: fromDocument.pageColor,
|
||||||
|
background,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePageBackgroundLayers(
|
||||||
|
background: DocPageBackground | null | undefined
|
||||||
|
): DocPageBackgroundLayers {
|
||||||
|
if (!background) return {}
|
||||||
|
|
||||||
|
const layers: DocPageBackgroundLayers = {}
|
||||||
|
|
||||||
|
if (background.gradientCss) {
|
||||||
|
layers.gradientCss = background.gradientCss
|
||||||
|
}
|
||||||
|
|
||||||
|
if (background.fillImage?.src) {
|
||||||
|
const mode = background.fillImage.mode
|
||||||
|
layers.fillImageStyle = {
|
||||||
|
backgroundImage: `url("${background.fillImage.src}")`,
|
||||||
|
backgroundRepeat: mode === "tile" ? "repeat" : "no-repeat",
|
||||||
|
backgroundSize: mode === "tile" ? "auto" : mode === "frame" ? "100% 100%" : "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (background.watermark) {
|
||||||
|
layers.watermarkStyle = {
|
||||||
|
text: background.watermark.kind === "text" ? background.watermark.text : undefined,
|
||||||
|
imageSrc: background.watermark.kind === "image" ? background.watermark.src : undefined,
|
||||||
|
color: background.watermark.color ?? "#b4b4b4",
|
||||||
|
opacity: background.watermark.opacity ?? 0.35,
|
||||||
|
rotationDeg: background.watermark.rotationDeg ?? -35,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return layers
|
||||||
|
}
|
||||||
135
lib/drive/doc-page-setup.test.ts
Normal file
135
lib/drive/doc-page-setup.test.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
import {
|
||||||
|
borderSzToPx,
|
||||||
|
bordersToLayoutCss,
|
||||||
|
buildPageSetupFromDraft,
|
||||||
|
cmToMm,
|
||||||
|
draftFromPageSetup,
|
||||||
|
matchPageFormatId,
|
||||||
|
mmToCm,
|
||||||
|
parseWordBorderSide,
|
||||||
|
resolveDocumentPageLayout,
|
||||||
|
twipsToMm,
|
||||||
|
} from "./doc-page-setup.ts"
|
||||||
|
|
||||||
|
describe("doc-page-setup", () => {
|
||||||
|
it("converts A4 twips to millimeters", () => {
|
||||||
|
assert.ok(Math.abs(twipsToMm(11906) - 210) < 1)
|
||||||
|
assert.ok(Math.abs(twipsToMm(16838) - 297) < 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("matches known page presets", () => {
|
||||||
|
assert.equal(matchPageFormatId(210, 297), "a4")
|
||||||
|
assert.equal(matchPageFormatId(216, 279), "letter")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("applies imported margins and custom size", () => {
|
||||||
|
const layout = resolveDocumentPageLayout({
|
||||||
|
widthMm: 210,
|
||||||
|
heightMm: 297,
|
||||||
|
marginsMm: { top: 25.4, right: 20, bottom: 25.4, left: 20 },
|
||||||
|
formatId: "a4",
|
||||||
|
})
|
||||||
|
assert.equal(layout.format.id, "a4")
|
||||||
|
assert.ok(layout.marginsPx.left < layout.marginsPx.top)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("maps Word border sides to CSS", () => {
|
||||||
|
const side = parseWordBorderSide('<w:top w:val="single" w:sz="12" w:color="FF0000"/>')
|
||||||
|
assert.equal(side.style, "solid")
|
||||||
|
assert.equal(side.color, "#FF0000")
|
||||||
|
assert.ok(borderSzToPx(12) >= 1)
|
||||||
|
|
||||||
|
const layout = bordersToLayoutCss({
|
||||||
|
top: side,
|
||||||
|
right: side,
|
||||||
|
bottom: side,
|
||||||
|
left: side,
|
||||||
|
offsetFrom: "page",
|
||||||
|
})
|
||||||
|
assert.ok(layout.sheetBorderCss?.top?.includes("solid"))
|
||||||
|
assert.equal(layout.textAreaBorderCss, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders text-offset borders inside margins", () => {
|
||||||
|
const side = parseWordBorderSide('<w:left w:val="double" w:sz="8" w:color="000000"/>')
|
||||||
|
const layout = bordersToLayoutCss({
|
||||||
|
top: { style: "none", widthPx: 0, color: "#000000" },
|
||||||
|
right: { style: "none", widthPx: 0, color: "#000000" },
|
||||||
|
bottom: { style: "none", widthPx: 0, color: "#000000" },
|
||||||
|
left: side,
|
||||||
|
offsetFrom: "text",
|
||||||
|
})
|
||||||
|
assert.ok(layout.textAreaBorderCss?.left?.includes("double"))
|
||||||
|
assert.equal(layout.sheetBorderCss, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds draft with margins in centimeters", () => {
|
||||||
|
const draft = draftFromPageSetup(
|
||||||
|
{
|
||||||
|
widthMm: 210,
|
||||||
|
heightMm: 297,
|
||||||
|
marginsMm: { top: 20, right: 20, bottom: 20, left: 20 },
|
||||||
|
formatId: "a4",
|
||||||
|
orientation: "portrait",
|
||||||
|
},
|
||||||
|
"letter"
|
||||||
|
)
|
||||||
|
assert.equal(draft.formatId, "a4")
|
||||||
|
assert.equal(draft.marginsCm.top, 2)
|
||||||
|
assert.equal(draft.orientation, "portrait")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("applies margins orientation and preserves imported borders", () => {
|
||||||
|
const importedBorders = {
|
||||||
|
top: { style: "solid" as const, widthPx: 2, color: "#000000" },
|
||||||
|
right: { style: "solid" as const, widthPx: 2, color: "#000000" },
|
||||||
|
bottom: { style: "solid" as const, widthPx: 2, color: "#000000" },
|
||||||
|
left: { style: "solid" as const, widthPx: 2, color: "#000000" },
|
||||||
|
offsetFrom: "text" as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
const setup = buildPageSetupFromDraft(
|
||||||
|
{
|
||||||
|
formatId: "a4",
|
||||||
|
orientation: "landscape",
|
||||||
|
marginsCm: { top: 2.5, right: 2, bottom: 2.5, left: 2 },
|
||||||
|
pageColor: "#fff8e1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
widthMm: 210,
|
||||||
|
heightMm: 297,
|
||||||
|
marginsMm: { top: 20, right: 20, bottom: 20, left: 20 },
|
||||||
|
formatId: "a4",
|
||||||
|
borders: importedBorders,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(setup.widthMm, 297)
|
||||||
|
assert.equal(setup.heightMm, 210)
|
||||||
|
assert.equal(setup.orientation, "landscape")
|
||||||
|
assert.equal(setup.marginsMm.top, cmToMm(2.5))
|
||||||
|
assert.equal(setup.pageColor, "#fff8e1")
|
||||||
|
assert.equal(setup.borders, importedBorders)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves margins when changing format from menubar", () => {
|
||||||
|
const setup = buildPageSetupFromDraft(
|
||||||
|
{
|
||||||
|
formatId: "a4",
|
||||||
|
orientation: "portrait",
|
||||||
|
marginsCm: { top: 3, right: 2.5, bottom: 3, left: 2.5 },
|
||||||
|
pageColor: "#ffffff",
|
||||||
|
},
|
||||||
|
null
|
||||||
|
)
|
||||||
|
const letter = buildPageSetupFromDraft(
|
||||||
|
{ ...draftFromPageSetup(setup, "a4"), formatId: "letter" },
|
||||||
|
setup
|
||||||
|
)
|
||||||
|
assert.equal(letter.formatId, "letter")
|
||||||
|
assert.equal(letter.marginsMm.left, cmToMm(2.5))
|
||||||
|
assert.equal(mmToCm(letter.marginsMm.left), 2.5)
|
||||||
|
})
|
||||||
|
})
|
||||||
398
lib/drive/doc-page-setup.ts
Normal file
398
lib/drive/doc-page-setup.ts
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
import type { DocPageBackground, DocPageBackgroundLayers } from "./doc-page-background.ts"
|
||||||
|
import { extractDocxPageBackground, resolvePageBackgroundLayers } from "./doc-page-background.ts"
|
||||||
|
import {
|
||||||
|
DEFAULT_PAGE_FORMAT_ID,
|
||||||
|
getPageFormat,
|
||||||
|
MM_TO_PX,
|
||||||
|
PAGE_FORMATS,
|
||||||
|
type PageFormat,
|
||||||
|
type PageFormatId,
|
||||||
|
} from "./page-formats.ts"
|
||||||
|
|
||||||
|
export type { DocPageBackground, DocPageBackgroundLayers, DocPageFillImage, DocPageWatermark } from "./doc-page-background.ts"
|
||||||
|
export { extractDocxPageBackground, resolvePageBackgroundLayers } from "./doc-page-background.ts"
|
||||||
|
|
||||||
|
const TWIPS_PER_MM = 1440 / 25.4
|
||||||
|
const FORMAT_MATCH_TOLERANCE_MM = 2
|
||||||
|
|
||||||
|
export type DocPageMarginsMm = {
|
||||||
|
top: number
|
||||||
|
right: number
|
||||||
|
bottom: number
|
||||||
|
left: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocPageBorderStyle = "solid" | "dashed" | "dotted" | "double" | "none"
|
||||||
|
|
||||||
|
export type DocPageBorderSide = {
|
||||||
|
style: DocPageBorderStyle
|
||||||
|
widthPx: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocPageBorders = {
|
||||||
|
top: DocPageBorderSide
|
||||||
|
right: DocPageBorderSide
|
||||||
|
bottom: DocPageBorderSide
|
||||||
|
left: DocPageBorderSide
|
||||||
|
offsetFrom?: "page" | "text"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocPageSetup = {
|
||||||
|
widthMm: number
|
||||||
|
heightMm: number
|
||||||
|
marginsMm: DocPageMarginsMm
|
||||||
|
formatId?: PageFormatId | null
|
||||||
|
orientation?: "portrait" | "landscape"
|
||||||
|
pageColor?: string | null
|
||||||
|
pageBackground?: DocPageBackground | null
|
||||||
|
borders?: DocPageBorders | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocPageBorderCss = {
|
||||||
|
top?: string
|
||||||
|
right?: string
|
||||||
|
bottom?: string
|
||||||
|
left?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocPageLayout = {
|
||||||
|
widthPx: number
|
||||||
|
heightPx: number
|
||||||
|
marginsPx: DocPageMarginsMm
|
||||||
|
format: PageFormat
|
||||||
|
pageColor: string
|
||||||
|
pageBackgroundLayers?: DocPageBackgroundLayers
|
||||||
|
sheetBorderCss?: DocPageBorderCss
|
||||||
|
textAreaBorderCss?: DocPageBorderCss
|
||||||
|
}
|
||||||
|
|
||||||
|
export function twipsToMm(twips: number): number {
|
||||||
|
return Math.round((twips / TWIPS_PER_MM) * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mmToPx(mm: number): number {
|
||||||
|
return Math.round(mm * MM_TO_PX)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultPageMarginsMm(): DocPageMarginsMm {
|
||||||
|
const cm = 2
|
||||||
|
return { top: cm * 10, right: cm * 10, bottom: cm * 10, left: cm * 10 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mmToCm(mm: number): number {
|
||||||
|
return Math.round((mm / 10) * 10) / 10
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cmToMm(cm: number): number {
|
||||||
|
return Math.round(cm * 10 * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPaperSizeLabel(format: PageFormat): string {
|
||||||
|
const w = format.widthMm / 10
|
||||||
|
const h = format.heightMm / 10
|
||||||
|
const fmt = (n: number) => n.toLocaleString("fr-FR", { maximumFractionDigits: 1 })
|
||||||
|
return `${format.label} (${fmt(w)} x ${fmt(h)} cm)`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePageColor(color: string | null | undefined): string {
|
||||||
|
if (!color || color.toLowerCase() === "#ffffff" || color.toLowerCase() === "white") {
|
||||||
|
return "#ffffff"
|
||||||
|
}
|
||||||
|
return color
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchPageFormatId(widthMm: number, heightMm: number): PageFormatId | null {
|
||||||
|
for (const format of PAGE_FORMATS) {
|
||||||
|
const direct =
|
||||||
|
Math.abs(format.widthMm - widthMm) <= FORMAT_MATCH_TOLERANCE_MM &&
|
||||||
|
Math.abs(format.heightMm - heightMm) <= FORMAT_MATCH_TOLERANCE_MM
|
||||||
|
const rotated =
|
||||||
|
Math.abs(format.widthMm - heightMm) <= FORMAT_MATCH_TOLERANCE_MM &&
|
||||||
|
Math.abs(format.heightMm - widthMm) <= FORMAT_MATCH_TOLERANCE_MM
|
||||||
|
if (direct || rotated) return format.id
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDocumentPageLayout(
|
||||||
|
pageSetup: DocPageSetup | null | undefined,
|
||||||
|
fallbackFormatId: PageFormatId = DEFAULT_PAGE_FORMAT_ID
|
||||||
|
): DocPageLayout {
|
||||||
|
if (!pageSetup) {
|
||||||
|
const format = getPageFormat(fallbackFormatId)
|
||||||
|
const marginPx = mmToPx(defaultPageMarginsMm().top)
|
||||||
|
return {
|
||||||
|
widthPx: mmToPx(format.widthMm),
|
||||||
|
heightPx: mmToPx(format.heightMm),
|
||||||
|
marginsPx: { top: marginPx, right: marginPx, bottom: marginPx, left: marginPx },
|
||||||
|
format,
|
||||||
|
pageColor: "#ffffff",
|
||||||
|
pageBackgroundLayers: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatId = pageSetup.formatId ?? matchPageFormatId(pageSetup.widthMm, pageSetup.heightMm)
|
||||||
|
const format = formatId ? getPageFormat(formatId) : null
|
||||||
|
const borderLayers = pageSetup.borders ? bordersToLayoutCss(pageSetup.borders) : {}
|
||||||
|
return {
|
||||||
|
widthPx: mmToPx(pageSetup.widthMm),
|
||||||
|
heightPx: mmToPx(pageSetup.heightMm),
|
||||||
|
marginsPx: {
|
||||||
|
top: mmToPx(pageSetup.marginsMm.top),
|
||||||
|
right: mmToPx(pageSetup.marginsMm.right),
|
||||||
|
bottom: mmToPx(pageSetup.marginsMm.bottom),
|
||||||
|
left: mmToPx(pageSetup.marginsMm.left),
|
||||||
|
},
|
||||||
|
format: format ?? {
|
||||||
|
id: fallbackFormatId,
|
||||||
|
label: `${pageSetup.widthMm} × ${pageSetup.heightMm} mm`,
|
||||||
|
widthMm: pageSetup.widthMm,
|
||||||
|
heightMm: pageSetup.heightMm,
|
||||||
|
},
|
||||||
|
pageColor: normalizePageColor(pageSetup.pageColor),
|
||||||
|
pageBackgroundLayers: resolvePageBackgroundLayers(pageSetup.pageBackground),
|
||||||
|
...borderLayers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapWordBorderStyle(val: string): DocPageBorderStyle {
|
||||||
|
switch (val.toLowerCase()) {
|
||||||
|
case "nil":
|
||||||
|
case "none":
|
||||||
|
return "none"
|
||||||
|
case "dotted":
|
||||||
|
return "dotted"
|
||||||
|
case "dashed":
|
||||||
|
case "dotdash":
|
||||||
|
case "dotdotdash":
|
||||||
|
return "dashed"
|
||||||
|
case "double":
|
||||||
|
case "triple":
|
||||||
|
return "double"
|
||||||
|
default:
|
||||||
|
return "solid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function borderSzToPx(sz: number): number {
|
||||||
|
if (sz <= 0) return 0
|
||||||
|
return Math.max(1, Math.round((sz / 8) * (96 / 72)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWordBorderColor(raw: string | undefined): string {
|
||||||
|
if (!raw || raw.toLowerCase() === "auto") return "#000000"
|
||||||
|
if (raw.startsWith("#")) return raw
|
||||||
|
if (/^[0-9a-f]{6}$/i.test(raw)) return `#${raw}`
|
||||||
|
return "#000000"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWordBorderSide(tagXml: string): DocPageBorderSide {
|
||||||
|
const val = tagXml.match(/\bw:val="([^"]+)"/i)?.[1] ?? "none"
|
||||||
|
const sz = Number.parseInt(tagXml.match(/\bw:sz="(\d+)"/i)?.[1] ?? "0", 10)
|
||||||
|
const color = parseWordBorderColor(tagXml.match(/\bw:color="([^"]+)"/i)?.[1])
|
||||||
|
return {
|
||||||
|
style: mapWordBorderStyle(val),
|
||||||
|
widthPx: borderSzToPx(Number.isFinite(sz) ? sz : 0),
|
||||||
|
color,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function borderSideToCss(side: DocPageBorderSide): string | undefined {
|
||||||
|
if (side.style === "none" || side.widthPx <= 0) return undefined
|
||||||
|
return `${side.widthPx}px ${side.style} ${side.color}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bordersToLayoutCss(borders: DocPageBorders): {
|
||||||
|
sheetBorderCss?: DocPageBorderCss
|
||||||
|
textAreaBorderCss?: DocPageBorderCss
|
||||||
|
} {
|
||||||
|
const css: DocPageBorderCss = {
|
||||||
|
top: borderSideToCss(borders.top),
|
||||||
|
right: borderSideToCss(borders.right),
|
||||||
|
bottom: borderSideToCss(borders.bottom),
|
||||||
|
left: borderSideToCss(borders.left),
|
||||||
|
}
|
||||||
|
const hasAny = Boolean(css.top || css.right || css.bottom || css.left)
|
||||||
|
if (!hasAny) return {}
|
||||||
|
if (borders.offsetFrom === "page") {
|
||||||
|
return { sheetBorderCss: css }
|
||||||
|
}
|
||||||
|
return { textAreaBorderCss: css }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePgBorders(sectionXml: string): DocPageBorders | null {
|
||||||
|
const block = sectionXml.match(/<w:pgBorders\b[^>]*>[\s\S]*?<\/w:pgBorders>/i)?.[0]
|
||||||
|
if (!block) return null
|
||||||
|
|
||||||
|
const offsetFrom =
|
||||||
|
block.match(/\bw:offsetFrom="page"/i) != null ? ("page" as const) : ("text" as const)
|
||||||
|
|
||||||
|
const readSide = (name: "top" | "right" | "bottom" | "left"): DocPageBorderSide => {
|
||||||
|
const match = block.match(new RegExp(`<w:${name}\\b[^>]*/?>`, "i"))
|
||||||
|
return match ? parseWordBorderSide(match[0]) : { style: "none", widthPx: 0, color: "#000000" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const borders: DocPageBorders = {
|
||||||
|
top: readSide("top"),
|
||||||
|
right: readSide("right"),
|
||||||
|
bottom: readSide("bottom"),
|
||||||
|
left: readSide("left"),
|
||||||
|
offsetFrom,
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAny = [borders.top, borders.right, borders.bottom, borders.left].some(
|
||||||
|
(side) => side.style !== "none" && side.widthPx > 0
|
||||||
|
)
|
||||||
|
return hasAny ? borders : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PageSetupDraft = {
|
||||||
|
formatId: PageFormatId
|
||||||
|
orientation: "portrait" | "landscape"
|
||||||
|
marginsCm: DocPageMarginsMm
|
||||||
|
pageColor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function draftFromPageSetup(
|
||||||
|
setup: DocPageSetup | null | undefined,
|
||||||
|
fallbackFormatId: PageFormatId
|
||||||
|
): PageSetupDraft {
|
||||||
|
const formatId =
|
||||||
|
setup?.formatId ??
|
||||||
|
(setup ? matchPageFormatId(setup.widthMm, setup.heightMm) : null) ??
|
||||||
|
fallbackFormatId
|
||||||
|
const margins = setup?.marginsMm ?? defaultPageMarginsMm()
|
||||||
|
return {
|
||||||
|
formatId,
|
||||||
|
orientation: setup?.orientation ?? "portrait",
|
||||||
|
marginsCm: {
|
||||||
|
top: mmToCm(margins.top),
|
||||||
|
right: mmToCm(margins.right),
|
||||||
|
bottom: mmToCm(margins.bottom),
|
||||||
|
left: mmToCm(margins.left),
|
||||||
|
},
|
||||||
|
pageColor: normalizePageColor(setup?.pageColor),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampMarginCm(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return 2
|
||||||
|
return Math.min(25, Math.max(0, Math.round(value * 10) / 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPageSetupFromDraft(
|
||||||
|
draft: PageSetupDraft,
|
||||||
|
previous: DocPageSetup | null | undefined
|
||||||
|
): DocPageSetup {
|
||||||
|
const format = getPageFormat(draft.formatId)
|
||||||
|
let widthMm = format.widthMm
|
||||||
|
let heightMm = format.heightMm
|
||||||
|
if (draft.orientation === "landscape") {
|
||||||
|
;[widthMm, heightMm] = [heightMm, widthMm]
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageColor = normalizePageColor(draft.pageColor)
|
||||||
|
|
||||||
|
return {
|
||||||
|
widthMm,
|
||||||
|
heightMm,
|
||||||
|
marginsMm: {
|
||||||
|
top: cmToMm(clampMarginCm(draft.marginsCm.top)),
|
||||||
|
right: cmToMm(clampMarginCm(draft.marginsCm.right)),
|
||||||
|
bottom: cmToMm(clampMarginCm(draft.marginsCm.bottom)),
|
||||||
|
left: cmToMm(clampMarginCm(draft.marginsCm.left)),
|
||||||
|
},
|
||||||
|
formatId: draft.formatId,
|
||||||
|
orientation: draft.orientation,
|
||||||
|
pageColor: pageColor === "#ffffff" ? null : pageColor,
|
||||||
|
pageBackground: previous?.pageBackground ?? null,
|
||||||
|
borders: previous?.borders ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPageSetupForFormat(
|
||||||
|
formatId: PageFormatId,
|
||||||
|
previous: DocPageSetup | null | undefined
|
||||||
|
): DocPageSetup {
|
||||||
|
return buildPageSetupFromDraft({ ...draftFromPageSetup(previous, formatId), formatId }, previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTwipsAttr(fragment: string, attr: string): number | null {
|
||||||
|
const match = fragment.match(new RegExp(`\\bw:${attr}="(\\d+)"`, "i"))
|
||||||
|
if (!match) return null
|
||||||
|
const value = Number.parseInt(match[1] ?? "", 10)
|
||||||
|
return Number.isFinite(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSectPr(sectionXml: string): Omit<DocPageSetup, "pageColor" | "pageBackground"> | null {
|
||||||
|
const pgSzMatch = sectionXml.match(/<w:pgSz\b[^>]*\/?>/i)
|
||||||
|
const pgMarMatch = sectionXml.match(/<w:pgMar\b[^>]*\/?>/i)
|
||||||
|
if (!pgSzMatch || !pgMarMatch) return null
|
||||||
|
|
||||||
|
const widthTwips = readTwipsAttr(pgSzMatch[0], "w")
|
||||||
|
const heightTwips = readTwipsAttr(pgSzMatch[0], "h")
|
||||||
|
if (widthTwips == null || heightTwips == null) return null
|
||||||
|
|
||||||
|
let widthMm = twipsToMm(widthTwips)
|
||||||
|
let heightMm = twipsToMm(heightTwips)
|
||||||
|
const orient = pgSzMatch[0].match(/\bw:orient="(landscape|portrait)"/i)?.[1]?.toLowerCase()
|
||||||
|
if (orient === "landscape" && widthMm < heightMm) {
|
||||||
|
;[widthMm, heightMm] = [heightMm, widthMm]
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = readTwipsAttr(pgMarMatch[0], "top")
|
||||||
|
const right = readTwipsAttr(pgMarMatch[0], "right")
|
||||||
|
const bottom = readTwipsAttr(pgMarMatch[0], "bottom")
|
||||||
|
const left = readTwipsAttr(pgMarMatch[0], "left")
|
||||||
|
if (top == null || right == null || bottom == null || left == null) return null
|
||||||
|
|
||||||
|
const marginsMm = {
|
||||||
|
top: twipsToMm(top),
|
||||||
|
right: twipsToMm(right),
|
||||||
|
bottom: twipsToMm(bottom),
|
||||||
|
left: twipsToMm(left),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
widthMm,
|
||||||
|
heightMm,
|
||||||
|
marginsMm,
|
||||||
|
formatId: matchPageFormatId(widthMm, heightMm),
|
||||||
|
orientation: orient === "landscape" ? "landscape" : "portrait",
|
||||||
|
borders: parsePgBorders(sectionXml),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read Word section properties and page background from a DOCX buffer. */
|
||||||
|
export async function extractDocxPageSetup(buffer: ArrayBuffer): Promise<DocPageSetup | null> {
|
||||||
|
try {
|
||||||
|
const { unzipSync } = await import("fflate")
|
||||||
|
const archive = unzipSync(new Uint8Array(buffer)) as Record<string, Uint8Array>
|
||||||
|
const documentXml = archive["word/document.xml"]
|
||||||
|
if (!documentXml) return null
|
||||||
|
|
||||||
|
const xml = new TextDecoder().decode(documentXml)
|
||||||
|
const sections = [...xml.matchAll(/<w:sectPr\b[^>]*>[\s\S]*?<\/w:sectPr>/gi)]
|
||||||
|
const sectionXml = sections.length > 0 ? sections[sections.length - 1]![0] : null
|
||||||
|
|
||||||
|
let layout: Omit<DocPageSetup, "pageColor" | "pageBackground"> | null = null
|
||||||
|
if (!sectionXml) {
|
||||||
|
const inline = xml.match(/<w:sectPr\b[^>]*\/>/i)
|
||||||
|
if (!inline) return null
|
||||||
|
layout = parseSectPr(inline[0])
|
||||||
|
} else {
|
||||||
|
layout = parseSectPr(sectionXml)
|
||||||
|
}
|
||||||
|
if (!layout) return null
|
||||||
|
|
||||||
|
const backgroundInfo = extractDocxPageBackground(archive, xml)
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
pageColor: backgroundInfo.pageColor ?? null,
|
||||||
|
pageBackground: backgroundInfo.background ?? null,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
16
lib/drive/docs-page-defaults.test.ts
Normal file
16
lib/drive/docs-page-defaults.test.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
import { pageSetupDraftsEqual, systemPageSetupDraft } from "./docs-page-defaults.ts"
|
||||||
|
|
||||||
|
describe("docs-page-defaults", () => {
|
||||||
|
it("compares page setup drafts", () => {
|
||||||
|
const base = systemPageSetupDraft("a4")
|
||||||
|
assert.ok(pageSetupDraftsEqual(base, { ...base }))
|
||||||
|
assert.ok(
|
||||||
|
!pageSetupDraftsEqual(base, {
|
||||||
|
...base,
|
||||||
|
marginsCm: { ...base.marginsCm, top: base.marginsCm.top + 0.1 },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
59
lib/drive/docs-page-defaults.ts
Normal file
59
lib/drive/docs-page-defaults.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
draftFromPageSetup,
|
||||||
|
type PageSetupDraft,
|
||||||
|
} from "./doc-page-setup.ts"
|
||||||
|
import { DEFAULT_PAGE_FORMAT_ID, type PageFormatId } from "./page-formats.ts"
|
||||||
|
|
||||||
|
const STORAGE_KEY = "ultidrive-docs-page-defaults"
|
||||||
|
|
||||||
|
export function systemPageSetupDraft(
|
||||||
|
fallbackFormatId: PageFormatId = DEFAULT_PAGE_FORMAT_ID
|
||||||
|
): PageSetupDraft {
|
||||||
|
return draftFromPageSetup(null, fallbackFormatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredDefaults(): PageSetupDraft | null {
|
||||||
|
if (typeof localStorage === "undefined") return null
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const parsed = JSON.parse(raw) as Partial<PageSetupDraft>
|
||||||
|
if (!parsed.formatId || !parsed.marginsCm) return null
|
||||||
|
return {
|
||||||
|
formatId: parsed.formatId,
|
||||||
|
orientation: parsed.orientation === "landscape" ? "landscape" : "portrait",
|
||||||
|
marginsCm: {
|
||||||
|
top: Number(parsed.marginsCm.top) || 0,
|
||||||
|
right: Number(parsed.marginsCm.right) || 0,
|
||||||
|
bottom: Number(parsed.marginsCm.bottom) || 0,
|
||||||
|
left: Number(parsed.marginsCm.left) || 0,
|
||||||
|
},
|
||||||
|
pageColor: parsed.pageColor ?? "#ffffff",
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readUserPageSetupDefaults(
|
||||||
|
fallbackFormatId: PageFormatId = DEFAULT_PAGE_FORMAT_ID
|
||||||
|
): PageSetupDraft {
|
||||||
|
return readStoredDefaults() ?? systemPageSetupDraft(fallbackFormatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveUserPageSetupDefaults(draft: PageSetupDraft): void {
|
||||||
|
if (typeof localStorage === "undefined") return
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(draft))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageSetupDraftsEqual(a: PageSetupDraft, b: PageSetupDraft): boolean {
|
||||||
|
return (
|
||||||
|
a.formatId === b.formatId &&
|
||||||
|
a.orientation === b.orientation &&
|
||||||
|
a.pageColor.toLowerCase() === b.pageColor.toLowerCase() &&
|
||||||
|
a.marginsCm.top === b.marginsCm.top &&
|
||||||
|
a.marginsCm.right === b.marginsCm.right &&
|
||||||
|
a.marginsCm.bottom === b.marginsCm.bottom &&
|
||||||
|
a.marginsCm.left === b.marginsCm.left
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -8,7 +8,7 @@ export type PageFormat = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 96 CSS px per inch, 25.4 mm per inch */
|
/** 96 CSS px per inch, 25.4 mm per inch */
|
||||||
const MM_TO_PX = 96 / 25.4
|
export const MM_TO_PX = 96 / 25.4
|
||||||
|
|
||||||
export const PAGE_FORMATS: PageFormat[] = [
|
export const PAGE_FORMATS: PageFormat[] = [
|
||||||
{ id: "a4", label: "A4", widthMm: 210, heightMm: 297 },
|
{ id: "a4", label: "A4", widthMm: 210, heightMm: 297 },
|
||||||
|
|||||||
20
lib/drive/richtext-content.test.ts
Normal file
20
lib/drive/richtext-content.test.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
import { isEmptyTipTapDoc } from "./richtext-content.ts"
|
||||||
|
|
||||||
|
describe("richtext-content", () => {
|
||||||
|
it("detects empty TipTap documents", () => {
|
||||||
|
assert.equal(isEmptyTipTapDoc(undefined), true)
|
||||||
|
assert.equal(
|
||||||
|
isEmptyTipTapDoc({ type: "doc", content: [{ type: "paragraph" }] }),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
isEmptyTipTapDoc({
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: "Hello" }] }],
|
||||||
|
}),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
25
lib/drive/richtext-content.ts
Normal file
25
lib/drive/richtext-content.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
type TipTapNode = {
|
||||||
|
type?: string
|
||||||
|
text?: string
|
||||||
|
content?: TipTapNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when TipTap JSON has no meaningful text (blank doc / empty paragraph). */
|
||||||
|
export function isEmptyTipTapDoc(content: Record<string, unknown> | null | undefined): boolean {
|
||||||
|
if (!content) return true
|
||||||
|
return !tipTapNodeHasText(content as TipTapNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function tipTapNodeHasText(node: TipTapNode | TipTapNode[] | null | undefined): boolean {
|
||||||
|
if (!node) return false
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
return node.some((child) => tipTapNodeHasText(child))
|
||||||
|
}
|
||||||
|
if (typeof node.text === "string" && node.text.trim() !== "") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
return node.content.some((child) => tipTapNodeHasText(child))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@ -1,7 +1,86 @@
|
|||||||
import mammoth from "mammoth"
|
import mammoth from "mammoth"
|
||||||
|
import {
|
||||||
|
extractDocxPageSetup,
|
||||||
|
type DocPageSetup,
|
||||||
|
} from "@/lib/drive/doc-page-setup"
|
||||||
|
|
||||||
export type TipTapJSON = Record<string, unknown>
|
export type TipTapJSON = Record<string, unknown>
|
||||||
|
|
||||||
|
export type RichTextImportResult = {
|
||||||
|
content: TipTapJSON
|
||||||
|
pageSetup?: DocPageSetup | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_ATTR_KEYS = ["src", "alt", "title", "width", "height"] as const
|
||||||
|
|
||||||
|
function imageNodeFromElement(el: HTMLElement): TipTapJSON | null {
|
||||||
|
const src = el.getAttribute("src")
|
||||||
|
if (!src) return null
|
||||||
|
const attrs: Record<string, unknown> = {
|
||||||
|
src,
|
||||||
|
alt: el.getAttribute("alt") ?? "",
|
||||||
|
}
|
||||||
|
const title = el.getAttribute("title")
|
||||||
|
if (title) attrs.title = title
|
||||||
|
const width = el.getAttribute("width")
|
||||||
|
const height = el.getAttribute("height")
|
||||||
|
if (width) attrs.width = Number(width) || width
|
||||||
|
if (height) attrs.height = Number(height) || height
|
||||||
|
return { type: "image", attrs }
|
||||||
|
}
|
||||||
|
|
||||||
|
function inlineContentFromElement(el: HTMLElement): TipTapJSON[] {
|
||||||
|
const content: TipTapJSON[] = []
|
||||||
|
for (const node of el.childNodes) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = node.textContent ?? ""
|
||||||
|
if (text) content.push({ type: "text", text })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) continue
|
||||||
|
const child = node as HTMLElement
|
||||||
|
const tag = child.tagName.toLowerCase()
|
||||||
|
if (tag === "img") {
|
||||||
|
const image = imageNodeFromElement(child)
|
||||||
|
if (image) content.push(image)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content.push(...inlineContentFromElement(child))
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep only attrs supported by @tiptap/extension-image. */
|
||||||
|
export function normalizeImportedTipTap(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" && 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 {
|
||||||
const parser = typeof DOMParser !== "undefined" ? new DOMParser() : null
|
const parser = typeof DOMParser !== "undefined" ? new DOMParser() : null
|
||||||
if (!parser) {
|
if (!parser) {
|
||||||
@ -24,11 +103,18 @@ function htmlToTipTapDoc(html: string): TipTapJSON {
|
|||||||
if (node.nodeType !== Node.ELEMENT_NODE) return
|
if (node.nodeType !== Node.ELEMENT_NODE) return
|
||||||
const el = node as HTMLElement
|
const el = node as HTMLElement
|
||||||
const tag = el.tagName.toLowerCase()
|
const tag = el.tagName.toLowerCase()
|
||||||
|
if (tag === "img") {
|
||||||
|
const image = imageNodeFromElement(el)
|
||||||
|
if (image) {
|
||||||
|
blocks.push({ type: "paragraph", content: [image] })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (tag === "p" || tag === "div") {
|
if (tag === "p" || tag === "div") {
|
||||||
const text = el.textContent ?? ""
|
const content = inlineContentFromElement(el)
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: "paragraph",
|
type: "paragraph",
|
||||||
content: text ? [{ type: "text", text }] : [],
|
content: content.length ? content : [],
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -45,7 +131,7 @@ function htmlToTipTapDoc(html: string): TipTapJSON {
|
|||||||
const listType = tag === "ul" ? "bulletList" : "orderedList"
|
const listType = tag === "ul" ? "bulletList" : "orderedList"
|
||||||
const items = Array.from(el.querySelectorAll(":scope > li")).map((li) => ({
|
const items = Array.from(el.querySelectorAll(":scope > li")).map((li) => ({
|
||||||
type: "listItem",
|
type: "listItem",
|
||||||
content: [{ type: "paragraph", content: [{ type: "text", text: li.textContent ?? "" }] }],
|
content: [{ type: "paragraph", content: inlineContentFromElement(li) }],
|
||||||
}))
|
}))
|
||||||
blocks.push({ type: listType, content: items })
|
blocks.push({ type: listType, content: items })
|
||||||
return
|
return
|
||||||
@ -57,21 +143,33 @@ function htmlToTipTapDoc(html: string): TipTapJSON {
|
|||||||
if (blocks.length === 0) {
|
if (blocks.length === 0) {
|
||||||
blocks.push({ type: "paragraph" })
|
blocks.push({ type: "paragraph" })
|
||||||
}
|
}
|
||||||
return { type: "doc", content: blocks }
|
return normalizeImportedTipTap({ type: "doc", content: blocks })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importDocxToTipTap(buffer: ArrayBuffer): Promise<TipTapJSON> {
|
export async function importDocxToTipTap(buffer: ArrayBuffer): Promise<RichTextImportResult> {
|
||||||
|
const pageSetup = await extractDocxPageSetup(buffer)
|
||||||
try {
|
try {
|
||||||
const { parseDOCX } = await import("@docen/import-docx")
|
const { parseDOCX } = await import("@docen/import-docx")
|
||||||
const content = await parseDOCX(buffer)
|
const content = await parseDOCX(buffer, { image: { crop: false } })
|
||||||
if (content && typeof content === "object") {
|
if (content && typeof content === "object") {
|
||||||
return content as TipTapJSON
|
return { content: normalizeImportedTipTap(content as TipTapJSON), pageSetup }
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
/* fallback mammoth */
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
console.warn("[richtext-import] parseDOCX failed, falling back to mammoth", error)
|
||||||
}
|
}
|
||||||
const result = await mammoth.convertToHtml({ arrayBuffer: buffer })
|
}
|
||||||
return htmlToTipTapDoc(result.value)
|
const result = await mammoth.convertToHtml(
|
||||||
|
{ arrayBuffer: buffer },
|
||||||
|
{
|
||||||
|
convertImage: mammoth.images.imgElement((image) =>
|
||||||
|
image.read("base64").then((imageBuffer) => ({
|
||||||
|
src: `data:${image.contentType};base64,${imageBuffer}`,
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return { content: htmlToTipTapDoc(result.value), pageSetup }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportTipTapToDocx(content: TipTapJSON): Promise<Blob> {
|
export async function exportTipTapToDocx(content: TipTapJSON): Promise<Blob> {
|
||||||
@ -88,21 +186,23 @@ export async function exportTipTapToDocx(content: TipTapJSON): Promise<Blob> {
|
|||||||
export async function importFileToTipTap(
|
export async function importFileToTipTap(
|
||||||
fileName: string,
|
fileName: string,
|
||||||
buffer: ArrayBuffer
|
buffer: ArrayBuffer
|
||||||
): Promise<TipTapJSON> {
|
): Promise<RichTextImportResult> {
|
||||||
const ext = fileName.split(".").pop()?.toLowerCase() ?? ""
|
const ext = fileName.split(".").pop()?.toLowerCase() ?? ""
|
||||||
if (ext === "docx" || ext === "docm") {
|
if (ext === "docx" || ext === "docm") {
|
||||||
return importDocxToTipTap(buffer)
|
return importDocxToTipTap(buffer)
|
||||||
}
|
}
|
||||||
const text = new TextDecoder().decode(buffer)
|
const text = new TextDecoder().decode(buffer)
|
||||||
if (ext === "html" || ext === "htm") {
|
if (ext === "html" || ext === "htm") {
|
||||||
return htmlToTipTapDoc(text)
|
return { content: htmlToTipTapDoc(text) }
|
||||||
}
|
}
|
||||||
const lines = text.split(/\r?\n/)
|
const lines = text.split(/\r?\n/)
|
||||||
return {
|
return {
|
||||||
|
content: {
|
||||||
type: "doc",
|
type: "doc",
|
||||||
content: lines.map((line) => ({
|
content: lines.map((line) => ({
|
||||||
type: "paragraph",
|
type: "paragraph",
|
||||||
content: line ? [{ type: "text", text: line }] : [],
|
content: line ? [{ type: "text", text: line }] : [],
|
||||||
})),
|
})),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import type { DocPageSetup } from "@/lib/drive/doc-page-setup"
|
||||||
|
|
||||||
export type RichTextSessionResponse = {
|
export type RichTextSessionResponse = {
|
||||||
roomId: string
|
roomId: string
|
||||||
canonicalPath: string
|
canonicalPath: string
|
||||||
@ -9,6 +11,7 @@ export type RichTextSessionResponse = {
|
|||||||
collaboration: boolean
|
collaboration: boolean
|
||||||
documentUrl?: string
|
documentUrl?: string
|
||||||
saveUrl?: string
|
saveUrl?: string
|
||||||
|
pageSetup?: DocPageSetup | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RichTextSaveStatus = "saved" | "saving" | "error" | "idle"
|
export type RichTextSaveStatus = "saved" | "saving" | "error" | "idle"
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import {
|
|||||||
} from "@/lib/drive/docs-file-menu-export"
|
} from "@/lib/drive/docs-file-menu-export"
|
||||||
import { openRichTextDocument } from "@/lib/drive/open-rich-text-document"
|
import { openRichTextDocument } from "@/lib/drive/open-rich-text-document"
|
||||||
import type { PageFormatId } from "@/lib/drive/page-formats"
|
import type { PageFormatId } from "@/lib/drive/page-formats"
|
||||||
|
import type { DocPageSetup } from "@/lib/drive/doc-page-setup"
|
||||||
import { useDocsKeyboardShortcutsStore } from "@/lib/stores/docs-keyboard-shortcuts-store"
|
import { useDocsKeyboardShortcutsStore } from "@/lib/stores/docs-keyboard-shortcuts-store"
|
||||||
|
|
||||||
function buildCopyFileName(originalName: string, siblingNames: string[]): string {
|
function buildCopyFileName(originalName: string, siblingNames: string[]): string {
|
||||||
@ -45,20 +46,24 @@ function buildCopyFileName(originalName: string, siblingNames: string[]): string
|
|||||||
export function useDocsFileMenu({
|
export function useDocsFileMenu({
|
||||||
file,
|
file,
|
||||||
editor,
|
editor,
|
||||||
pageFormatId,
|
pageSetup,
|
||||||
onPageFormatChange,
|
fallbackFormatId,
|
||||||
|
onPageSetupApply,
|
||||||
onShareClick,
|
onShareClick,
|
||||||
onRenameRequest,
|
onRenameRequest,
|
||||||
onFileMoved,
|
onFileMoved,
|
||||||
|
onPurgeSidecarAndReimport,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
file?: DriveFileInfo
|
file?: DriveFileInfo
|
||||||
editor: Editor | null
|
editor: Editor | null
|
||||||
pageFormatId: PageFormatId
|
pageSetup: DocPageSetup | null
|
||||||
onPageFormatChange: (id: PageFormatId) => void
|
fallbackFormatId: PageFormatId
|
||||||
|
onPageSetupApply: (setup: DocPageSetup) => void
|
||||||
onShareClick?: () => void
|
onShareClick?: () => void
|
||||||
onRenameRequest?: () => void
|
onRenameRequest?: () => void
|
||||||
onFileMoved?: (newPath: string) => void
|
onFileMoved?: (newPath: string) => void
|
||||||
|
onPurgeSidecarAndReimport?: () => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -192,11 +197,15 @@ export function useDocsFileMenu({
|
|||||||
onSecurityLimits: () => soon("Limites de sécurité"),
|
onSecurityLimits: () => soon("Limites de sécurité"),
|
||||||
onPageSetup: () => setPageSetupOpen(true),
|
onPageSetup: () => setPageSetupOpen(true),
|
||||||
onPrint: () => window.print(),
|
onPrint: () => window.print(),
|
||||||
|
...(onPurgeSidecarAndReimport
|
||||||
|
? { onPurgeSidecarAndReimport: () => void onPurgeSidecarAndReimport() }
|
||||||
|
: {}),
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
downloadFormat,
|
downloadFormat,
|
||||||
makeCopy,
|
makeCopy,
|
||||||
moveToTrash,
|
moveToTrash,
|
||||||
|
onPurgeSidecarAndReimport,
|
||||||
onRenameRequest,
|
onRenameRequest,
|
||||||
onShareClick,
|
onShareClick,
|
||||||
siblingNames,
|
siblingNames,
|
||||||
@ -260,8 +269,9 @@ export function useDocsFileMenu({
|
|||||||
<DocsPageSetupDialog
|
<DocsPageSetupDialog
|
||||||
open={pageSetupOpen}
|
open={pageSetupOpen}
|
||||||
onOpenChange={setPageSetupOpen}
|
onOpenChange={setPageSetupOpen}
|
||||||
pageFormatId={pageFormatId}
|
pageSetup={pageSetup}
|
||||||
onPageFormatChange={onPageFormatChange}
|
fallbackFormatId={fallbackFormatId}
|
||||||
|
onApply={onPageSetupApply}
|
||||||
/>
|
/>
|
||||||
<DocsDetailsDialog open={detailsOpen} onOpenChange={setDetailsOpen} file={file} />
|
<DocsDetailsDialog open={detailsOpen} onOpenChange={setDetailsOpen} file={file} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@ -3,9 +3,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --hostname 0.0.0.0 --webpack",
|
"dev": "next dev --hostname 0.0.0.0 --port 3004 --webpack",
|
||||||
"dev:turbo": "next dev --hostname 0.0.0.0",
|
"dev:turbo": "next dev --hostname 0.0.0.0 --port 3004",
|
||||||
"dev:webpack": "next dev --hostname 0.0.0.0 --webpack",
|
"dev:webpack": "next dev --hostname 0.0.0.0 --port 3004 --webpack",
|
||||||
"build": "next build --webpack",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
@ -94,6 +94,7 @@
|
|||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"embla-carousel-react": "8.6.0",
|
"embla-carousel-react": "8.6.0",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"fuse.js": "^7.3.0",
|
"fuse.js": "^7.3.0",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
|
|||||||
@ -227,6 +227,9 @@ importers:
|
|||||||
emoji-mart:
|
emoji-mart:
|
||||||
specifier: ^5.6.0
|
specifier: ^5.6.0
|
||||||
version: 5.6.0
|
version: 5.6.0
|
||||||
|
fflate:
|
||||||
|
specifier: ^0.8.3
|
||||||
|
version: 0.8.3
|
||||||
fuse.js:
|
fuse.js:
|
||||||
specifier: ^7.3.0
|
specifier: ^7.3.0
|
||||||
version: 7.3.0
|
version: 7.3.0
|
||||||
@ -2059,6 +2062,9 @@ packages:
|
|||||||
fflate@0.8.2:
|
fflate@0.8.2:
|
||||||
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
|
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
|
||||||
|
|
||||||
|
fflate@0.8.3:
|
||||||
|
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||||
|
|
||||||
fraction.js@5.3.4:
|
fraction.js@5.3.4:
|
||||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||||
|
|
||||||
@ -4323,6 +4329,8 @@ snapshots:
|
|||||||
|
|
||||||
fflate@0.8.2: {}
|
fflate@0.8.2: {}
|
||||||
|
|
||||||
|
fflate@0.8.3: {}
|
||||||
|
|
||||||
fraction.js@5.3.4: {}
|
fraction.js@5.3.4: {}
|
||||||
|
|
||||||
fsevents@2.3.2:
|
fsevents@2.3.2:
|
||||||
|
|||||||
@ -31,6 +31,11 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ultidrive-richtext-editor img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.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));
|
||||||
@ -456,6 +461,10 @@ html.dark .docs-menu-item-icon {
|
|||||||
border: 1px solid #dadce0;
|
border: 1px solid #dadce0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ultidrive-docs-page--imported-border {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
.ultidrive-docs-page .ultidrive-richtext-editor {
|
.ultidrive-docs-page .ultidrive-richtext-editor {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user