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_CLIENT_ID=ulti-backend
|
||||
# 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
|
||||
# Cookies session Secure (auto: true seulement si NEXT_PUBLIC_APP_URL est https://)
|
||||
# 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 { getGuestEditorIdentity } from "@/lib/drive/guest-editor-identity"
|
||||
import type { RichTextSessionResponse } from "@/lib/drive/richtext-types"
|
||||
import type { DocPageSetup } from "@/lib/drive/doc-page-setup"
|
||||
import { fetchPublicShareBlob } from "@/lib/api/public-share"
|
||||
|
||||
function fileNameFromPath(filePath: string, fallback?: string): string {
|
||||
@ -99,7 +100,11 @@ export function PublicRichTextEditor({
|
||||
)
|
||||
|
||||
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(
|
||||
`/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 { HocuspocusProvider } from "@hocuspocus/provider"
|
||||
import * as Y from "yjs"
|
||||
import { toast } from "sonner"
|
||||
import { DocsChrome } from "@/components/drive/richtext/docs-chrome"
|
||||
import { DocsPageView, DocsStatusBar } from "@/components/drive/richtext/docs-page-view"
|
||||
import { 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 { apiClient } from "@/lib/api/client"
|
||||
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 { isUltidocPath } from "@/lib/drive/richtext-formats"
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 2000
|
||||
/** Align with Hocuspocus store debounce + buffer */
|
||||
@ -58,7 +68,11 @@ export function RichTextDocumentEditor({
|
||||
userColor: string
|
||||
onSaveStatus?: (status: RichTextSaveStatus) => void
|
||||
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
|
||||
}) {
|
||||
const editable = mode === "edit"
|
||||
@ -73,15 +87,26 @@ export function RichTextDocumentEditor({
|
||||
const [collabSynced, setCollabSynced] = useState(false)
|
||||
const [collabError, setCollabError] = useState<string | null>(null)
|
||||
const [importDone, setImportDone] = useState(!session.importRequired)
|
||||
const [contentImportPending, setContentImportPending] = useState(session.importRequired)
|
||||
const [importedContent, setImportedContent] = useState<Record<string, unknown> | null>(null)
|
||||
const [documentPageSetup, setDocumentPageSetup] = useState<DocPageSetup | null>(
|
||||
session.pageSetup ?? null
|
||||
)
|
||||
const [saveStatus, setSaveStatus] = useState<RichTextSaveStatus>("idle")
|
||||
const [pageCount, setPageCount] = useState(1)
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const saveIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const saveStatusRef = useRef<RichTextSaveStatus>("idle")
|
||||
const reloadAfterReimportRef = useRef(false)
|
||||
|
||||
const { settings, setPageFormatId, setZoom, toggleSpellcheck, toggleChromeCollapsed } =
|
||||
useDocsViewSettings()
|
||||
const presenceUsers = useCollabPresence(provider, { name: userName, color: userColor })
|
||||
const pageLayout = useMemo(
|
||||
() => resolveDocumentPageLayout(documentPageSetup, settings.pageFormatId),
|
||||
[documentPageSetup, settings.pageFormatId]
|
||||
)
|
||||
const activePageFormatId = pageLayout.format.id
|
||||
|
||||
const reportSaveStatus = useCallback(
|
||||
(status: RichTextSaveStatus) => {
|
||||
@ -92,10 +117,93 @@ export function RichTextDocumentEditor({
|
||||
[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) => {
|
||||
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(() => {
|
||||
if (saveStatusRef.current !== "saving") {
|
||||
reportSaveStatus("saving")
|
||||
@ -114,7 +222,38 @@ export function RichTextDocumentEditor({
|
||||
}, [])
|
||||
|
||||
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
|
||||
void (async () => {
|
||||
reportSaveStatus("saving")
|
||||
@ -123,17 +262,29 @@ export function RichTextDocumentEditor({
|
||||
const buf = fetchSourceBytes
|
||||
? await fetchSourceBytes(source)
|
||||
: 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
|
||||
const payload = { source_path: source, content }
|
||||
const payload = {
|
||||
source_path: source,
|
||||
content: imported.content,
|
||||
pageSetup: imported.pageSetup ?? undefined,
|
||||
}
|
||||
if (importApi) {
|
||||
await importApi(payload)
|
||||
} else {
|
||||
await apiClient.post("/richtext/import", payload)
|
||||
}
|
||||
if (!cancelled) {
|
||||
setImportedContent(imported.content as Record<string, unknown>)
|
||||
if (imported.pageSetup) setDocumentPageSetup(imported.pageSetup)
|
||||
setContentImportPending(false)
|
||||
setImportDone(true)
|
||||
reportSaveStatus("saved")
|
||||
if (reloadAfterReimportRef.current) {
|
||||
reloadAfterReimportRef.current = false
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) reportSaveStatus("error")
|
||||
@ -142,7 +293,7 @@ export function RichTextDocumentEditor({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [session, importDone, fetchSourceBytes, importApi, reportSaveStatus])
|
||||
}, [contentImportPending, importDone, session, fetchSourceBytes, importApi, reportSaveStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (!collaboration || !ydoc || !importDone) return
|
||||
@ -263,8 +414,10 @@ export function RichTextDocumentEditor({
|
||||
const fileMenu = useDocsFileMenu({
|
||||
file: chrome?.file,
|
||||
editor,
|
||||
pageFormatId: settings.pageFormatId,
|
||||
onPageFormatChange: setPageFormatId,
|
||||
pageSetup: documentPageSetup,
|
||||
fallbackFormatId: settings.pageFormatId,
|
||||
onPageSetupApply: (setup) => void persistPageSetup(setup),
|
||||
onPurgeSidecarAndReimport: () => void handlePurgeSidecarAndReimport(),
|
||||
onShareClick: chrome?.onShareClick,
|
||||
onRenameRequest: chrome?.onRenameRequest,
|
||||
onFileMoved: chrome?.onFileMoved,
|
||||
@ -289,20 +442,43 @@ export function RichTextDocumentEditor({
|
||||
: undefined
|
||||
|
||||
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
|
||||
void (async () => {
|
||||
try {
|
||||
let parsed: { content?: Record<string, unknown> }
|
||||
let parsed: { content?: Record<string, unknown>; pageSetup?: DocPageSetup }
|
||||
if (session.documentUrl) {
|
||||
const res = await fetch(session.documentUrl)
|
||||
if (!res.ok) throw new Error("load failed")
|
||||
parsed = JSON.parse(await res.text()) as { content?: Record<string, unknown> }
|
||||
parsed = JSON.parse(await res.text()) as { content?: Record<string, unknown>; pageSetup?: DocPageSetup }
|
||||
} else {
|
||||
const blob = await apiClient.getBlob(driveDownloadApiPath(session.canonicalPath))
|
||||
parsed = JSON.parse(await blob.text()) as { content?: Record<string, unknown> }
|
||||
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 {
|
||||
/* blank */
|
||||
}
|
||||
@ -310,7 +486,17 @@ export function RichTextDocumentEditor({
|
||||
return () => {
|
||||
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) {
|
||||
return (
|
||||
@ -322,7 +508,9 @@ export function RichTextDocumentEditor({
|
||||
|
||||
if (!editorEnabled || !editor) {
|
||||
const statusText =
|
||||
session.importRequired && !importDone
|
||||
contentImportPending && !importDone
|
||||
? "Import du document…"
|
||||
: session.importRequired && !importDone
|
||||
? "Import du document…"
|
||||
: collaboration && !collabSynced
|
||||
? "Connexion à la collaboration…"
|
||||
@ -334,8 +522,8 @@ export function RichTextDocumentEditor({
|
||||
{...chromeProps}
|
||||
saveStatus={saveStatus}
|
||||
presenceUsers={presenceUsers}
|
||||
pageFormatId={settings.pageFormatId}
|
||||
onPageFormatChange={setPageFormatId}
|
||||
pageFormatId={activePageFormatId}
|
||||
onPageFormatChange={handlePageFormatChange}
|
||||
zoom={settings.zoom}
|
||||
onZoomChange={setZoom}
|
||||
/>
|
||||
@ -354,8 +542,8 @@ export function RichTextDocumentEditor({
|
||||
{...chromeProps}
|
||||
saveStatus={saveStatus}
|
||||
presenceUsers={presenceUsers}
|
||||
pageFormatId={settings.pageFormatId}
|
||||
onPageFormatChange={setPageFormatId}
|
||||
pageFormatId={activePageFormatId}
|
||||
onPageFormatChange={handlePageFormatChange}
|
||||
zoom={settings.zoom}
|
||||
onZoomChange={setZoom}
|
||||
/>
|
||||
@ -375,7 +563,7 @@ export function RichTextDocumentEditor({
|
||||
{chrome ? (
|
||||
<DocsPageView
|
||||
editor={editor}
|
||||
pageFormatId={settings.pageFormatId}
|
||||
pageLayout={pageLayout}
|
||||
zoom={settings.zoom}
|
||||
editable={editable}
|
||||
onPageCountChange={handlePageCountChange}
|
||||
@ -386,7 +574,7 @@ export function RichTextDocumentEditor({
|
||||
</div>
|
||||
)}
|
||||
{chrome ? (
|
||||
<DocsStatusBar pageFormatId={settings.pageFormatId} pageCount={pageCount} />
|
||||
<DocsStatusBar pageLayout={pageLayout} pageCount={pageCount} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -44,17 +44,19 @@ export function RichTextEditor({ fileId }: { fileId: string }) {
|
||||
const title = displayFileBaseName(fileName)
|
||||
useDriveDocumentTitle(title)
|
||||
|
||||
const sessionReturnTo = readDriveEditorReturnTo()
|
||||
const backHref = useMemo(
|
||||
() =>
|
||||
const [backHref, setBackHref] = useState("/drive")
|
||||
|
||||
useEffect(() => {
|
||||
if (!displayPath) return
|
||||
setBackHref(
|
||||
resolveDriveEditReturnTo(
|
||||
null,
|
||||
displayPath,
|
||||
(folderPath) => driveFolderHref("files", folderPath),
|
||||
sessionReturnTo
|
||||
),
|
||||
[displayPath, sessionReturnTo]
|
||||
)
|
||||
readDriveEditorReturnTo()
|
||||
)
|
||||
)
|
||||
}, [displayPath])
|
||||
|
||||
const { data: sharesData } = useDriveShares(displayPath, Boolean(displayPath))
|
||||
const { rename } = useDriveMutations()
|
||||
|
||||
@ -58,6 +58,8 @@ export type DocsFileMenuActions = {
|
||||
onSecurityLimits: () => void
|
||||
onPageSetup: () => void
|
||||
onPrint: () => void
|
||||
/** Dev-only: purge .ultidoc.json sidecar and force DOCX reimport. */
|
||||
onPurgeSidecarAndReimport?: () => void
|
||||
}
|
||||
|
||||
function MenuIcon({ children }: { children: ReactNode }) {
|
||||
@ -282,6 +284,22 @@ export function DocsFileMenu({
|
||||
Configuration de la page
|
||||
</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}>
|
||||
<MenuIcon>
|
||||
<Printer className="size-4" />
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@ -9,79 +10,292 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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 {
|
||||
DRIVE_BTN_GHOST,
|
||||
DRIVE_BTN_PRIMARY,
|
||||
DRIVE_DIALOG_BODY,
|
||||
DRIVE_DIALOG_CONTENT,
|
||||
DRIVE_DIALOG_FOOTER,
|
||||
DRIVE_DIALOG_HEADER,
|
||||
DRIVE_DIALOG_OVERLAY,
|
||||
DRIVE_TEXT_SECONDARY,
|
||||
DRIVE_TEXT_TITLE,
|
||||
} 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 { 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({
|
||||
open,
|
||||
onOpenChange,
|
||||
pageFormatId,
|
||||
onPageFormatChange,
|
||||
pageSetup,
|
||||
fallbackFormatId,
|
||||
onApply,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
pageFormatId: PageFormatId
|
||||
onPageFormatChange: (id: PageFormatId) => void
|
||||
pageSetup: DocPageSetup | null
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
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}>
|
||||
<DialogTitle className={cn("text-base font-medium", DRIVE_TEXT_TITLE)}>
|
||||
<DialogHeader className="space-y-0 px-5 py-3 text-left">
|
||||
<DialogTitle className={cn("text-lg font-normal", DRIVE_TEXT_TITLE)}>
|
||||
Configuration de la page
|
||||
</DialogTitle>
|
||||
<DialogDescription className={cn("text-sm", DRIVE_TEXT_SECONDARY)}>
|
||||
Format de page utilisé pour l'affichage et l'impression.
|
||||
<DialogDescription className="sr-only">
|
||||
Format, orientation, couleur et marges du document.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className={cn(DRIVE_DIALOG_BODY, "space-y-1")}>
|
||||
{PAGE_FORMATS.map((format) => (
|
||||
<button
|
||||
key={format.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm hover:bg-accent",
|
||||
pageFormatId === format.id && "bg-accent"
|
||||
)}
|
||||
onClick={() => onPageFormatChange(format.id)}
|
||||
|
||||
<Tabs defaultValue="pages" className="gap-0">
|
||||
<div className="px-5">
|
||||
<TabsList className="h-9 w-full gap-0 rounded-lg bg-[#f1f3f4] p-1 dark:bg-muted">
|
||||
<TabsTrigger
|
||||
value="pages"
|
||||
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]"
|
||||
>
|
||||
<span>{format.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{format.widthMm} × {format.heightMm} mm
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter className={DRIVE_DIALOG_FOOTER}>
|
||||
<Button
|
||||
type="button"
|
||||
className={DRIVE_BTN_PRIMARY}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
Pages
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="pageless"
|
||||
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>
|
||||
</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
|
||||
type="button"
|
||||
variant="link"
|
||||
className="order-1 h-8 px-0 text-sm font-normal text-[#1a73e8] hover:text-[#174ea6]"
|
||||
onClick={handleSaveDefaults}
|
||||
>
|
||||
Enregistrer comme valeurs par défaut
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
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)}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className={cn(DRIVE_BTN_PRIMARY, "order-3 h-8 rounded-full px-5")}
|
||||
onClick={handleApply}
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -2,13 +2,7 @@
|
||||
|
||||
import { memo, useEffect, useRef, useState } from "react"
|
||||
import { EditorContent, type Editor } from "@tiptap/react"
|
||||
import {
|
||||
getPageFormat,
|
||||
PAGE_MARGIN_PX,
|
||||
pageFormatHeightPx,
|
||||
pageFormatWidthPx,
|
||||
type PageFormatId,
|
||||
} from "@/lib/drive/page-formats"
|
||||
import type { DocPageLayout } from "@/lib/drive/doc-page-setup"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { focusEditorAtPointer } from "@/lib/drive/focus-editor-at-pointer"
|
||||
|
||||
@ -29,20 +23,20 @@ function measureProseContentHeight(prose: HTMLElement): number {
|
||||
|
||||
function DocsPageViewInner({
|
||||
editor,
|
||||
pageFormatId,
|
||||
pageLayout,
|
||||
zoom,
|
||||
editable,
|
||||
onPageCountChange,
|
||||
}: {
|
||||
editor: Editor
|
||||
pageFormatId: PageFormatId
|
||||
pageLayout: DocPageLayout
|
||||
zoom: number
|
||||
editable: boolean
|
||||
onPageCountChange?: (count: number) => void
|
||||
}) {
|
||||
const format = getPageFormat(pageFormatId)
|
||||
const pageWidth = pageFormatWidthPx(format)
|
||||
const pageHeight = pageFormatHeightPx(format)
|
||||
const pageWidth = pageLayout.widthPx
|
||||
const pageHeight = pageLayout.heightPx
|
||||
const margins = pageLayout.marginsPx
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const [pageCount, setPageCount] = useState(1)
|
||||
@ -78,13 +72,9 @@ function DocsPageViewInner({
|
||||
if (!prose) return
|
||||
|
||||
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))
|
||||
setPageCount((prev) => {
|
||||
if (prev === count) return prev
|
||||
onPageCountChangeRef.current?.(count)
|
||||
return count
|
||||
})
|
||||
setPageCount((prev) => (prev === count ? prev : count))
|
||||
}
|
||||
|
||||
const scheduleMeasure = () => {
|
||||
@ -105,12 +95,71 @@ function DocsPageViewInner({
|
||||
ro?.disconnect()
|
||||
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 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 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 (
|
||||
<div
|
||||
@ -142,28 +191,59 @@ function DocsPageViewInner({
|
||||
{Array.from({ length: pageCount }, (_, index) => (
|
||||
<div
|
||||
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={{
|
||||
top: index * (pageHeight + PAGE_GAP_PX),
|
||||
width: pageWidth,
|
||||
height: pageHeight,
|
||||
backgroundColor: pageBackground,
|
||||
boxShadow:
|
||||
"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
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="ultidrive-docs-editor-surface relative z-10"
|
||||
style={{
|
||||
padding: PAGE_MARGIN_PX,
|
||||
padding: `${margins.top}px ${margins.right}px ${margins.bottom}px ${margins.left}px`,
|
||||
minHeight: stackHeight,
|
||||
["--docs-prose-min-height" as string]: `${Math.max(
|
||||
pageHeight - PAGE_MARGIN_PX * 2,
|
||||
proseMinHeight
|
||||
)}px`,
|
||||
["--docs-prose-min-height" as string]: `${innerMinHeight}px`,
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
if (!editable) return
|
||||
@ -188,16 +268,14 @@ function DocsPageViewInner({
|
||||
export const DocsPageView = memo(DocsPageViewInner)
|
||||
|
||||
export function DocsStatusBar({
|
||||
pageFormatId,
|
||||
pageLayout,
|
||||
pageCount,
|
||||
className,
|
||||
}: {
|
||||
pageFormatId: PageFormatId
|
||||
pageLayout: DocPageLayout
|
||||
pageCount: number
|
||||
className?: string
|
||||
}) {
|
||||
const format = getPageFormat(pageFormatId)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@ -207,7 +285,7 @@ export function DocsStatusBar({
|
||||
>
|
||||
<span>Page 1 sur {pageCount}</span>
|
||||
<span>
|
||||
{format.label} ({format.widthMm} × {format.heightMm} mm)
|
||||
{pageLayout.format.label} ({pageLayout.format.widthMm} × {pageLayout.format.heightMm} mm)
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -7,7 +7,7 @@ function trimSlash(url: string) {
|
||||
/** Public app origin for OAuth redirects and post-login navigation (never 0.0.0.0). */
|
||||
export function getAppOrigin(): string {
|
||||
const raw = (
|
||||
process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"
|
||||
process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3004"
|
||||
).replace(/\/$/, "")
|
||||
|
||||
try {
|
||||
@ -17,7 +17,7 @@ export function getAppOrigin(): string {
|
||||
}
|
||||
return url.origin
|
||||
} 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 */
|
||||
const MM_TO_PX = 96 / 25.4
|
||||
export const MM_TO_PX = 96 / 25.4
|
||||
|
||||
export const PAGE_FORMATS: PageFormat[] = [
|
||||
{ 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 {
|
||||
extractDocxPageSetup,
|
||||
type DocPageSetup,
|
||||
} from "@/lib/drive/doc-page-setup"
|
||||
|
||||
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 {
|
||||
const parser = typeof DOMParser !== "undefined" ? new DOMParser() : null
|
||||
if (!parser) {
|
||||
@ -24,11 +103,18 @@ function htmlToTipTapDoc(html: string): TipTapJSON {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return
|
||||
const el = node as HTMLElement
|
||||
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") {
|
||||
const text = el.textContent ?? ""
|
||||
const content = inlineContentFromElement(el)
|
||||
blocks.push({
|
||||
type: "paragraph",
|
||||
content: text ? [{ type: "text", text }] : [],
|
||||
content: content.length ? content : [],
|
||||
})
|
||||
return
|
||||
}
|
||||
@ -45,7 +131,7 @@ function htmlToTipTapDoc(html: string): TipTapJSON {
|
||||
const listType = tag === "ul" ? "bulletList" : "orderedList"
|
||||
const items = Array.from(el.querySelectorAll(":scope > li")).map((li) => ({
|
||||
type: "listItem",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: li.textContent ?? "" }] }],
|
||||
content: [{ type: "paragraph", content: inlineContentFromElement(li) }],
|
||||
}))
|
||||
blocks.push({ type: listType, content: items })
|
||||
return
|
||||
@ -57,21 +143,33 @@ function htmlToTipTapDoc(html: string): TipTapJSON {
|
||||
if (blocks.length === 0) {
|
||||
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 {
|
||||
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") {
|
||||
return content as TipTapJSON
|
||||
return { content: normalizeImportedTipTap(content as TipTapJSON), pageSetup }
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.warn("[richtext-import] parseDOCX failed, falling back to mammoth", error)
|
||||
}
|
||||
} catch {
|
||||
/* fallback mammoth */
|
||||
}
|
||||
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> {
|
||||
@ -88,21 +186,23 @@ export async function exportTipTapToDocx(content: TipTapJSON): Promise<Blob> {
|
||||
export async function importFileToTipTap(
|
||||
fileName: string,
|
||||
buffer: ArrayBuffer
|
||||
): Promise<TipTapJSON> {
|
||||
): Promise<RichTextImportResult> {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() ?? ""
|
||||
if (ext === "docx" || ext === "docm") {
|
||||
return importDocxToTipTap(buffer)
|
||||
}
|
||||
const text = new TextDecoder().decode(buffer)
|
||||
if (ext === "html" || ext === "htm") {
|
||||
return htmlToTipTapDoc(text)
|
||||
return { content: htmlToTipTapDoc(text) }
|
||||
}
|
||||
const lines = text.split(/\r?\n/)
|
||||
return {
|
||||
type: "doc",
|
||||
content: lines.map((line) => ({
|
||||
type: "paragraph",
|
||||
content: line ? [{ type: "text", text: line }] : [],
|
||||
})),
|
||||
content: {
|
||||
type: "doc",
|
||||
content: lines.map((line) => ({
|
||||
type: "paragraph",
|
||||
content: line ? [{ type: "text", text: line }] : [],
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import type { DocPageSetup } from "@/lib/drive/doc-page-setup"
|
||||
|
||||
export type RichTextSessionResponse = {
|
||||
roomId: string
|
||||
canonicalPath: string
|
||||
@ -9,6 +11,7 @@ export type RichTextSessionResponse = {
|
||||
collaboration: boolean
|
||||
documentUrl?: string
|
||||
saveUrl?: string
|
||||
pageSetup?: DocPageSetup | null
|
||||
}
|
||||
|
||||
export type RichTextSaveStatus = "saved" | "saving" | "error" | "idle"
|
||||
|
||||
@ -23,6 +23,7 @@ import {
|
||||
} from "@/lib/drive/docs-file-menu-export"
|
||||
import { openRichTextDocument } from "@/lib/drive/open-rich-text-document"
|
||||
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"
|
||||
|
||||
function buildCopyFileName(originalName: string, siblingNames: string[]): string {
|
||||
@ -45,20 +46,24 @@ function buildCopyFileName(originalName: string, siblingNames: string[]): string
|
||||
export function useDocsFileMenu({
|
||||
file,
|
||||
editor,
|
||||
pageFormatId,
|
||||
onPageFormatChange,
|
||||
pageSetup,
|
||||
fallbackFormatId,
|
||||
onPageSetupApply,
|
||||
onShareClick,
|
||||
onRenameRequest,
|
||||
onFileMoved,
|
||||
onPurgeSidecarAndReimport,
|
||||
disabled,
|
||||
}: {
|
||||
file?: DriveFileInfo
|
||||
editor: Editor | null
|
||||
pageFormatId: PageFormatId
|
||||
onPageFormatChange: (id: PageFormatId) => void
|
||||
pageSetup: DocPageSetup | null
|
||||
fallbackFormatId: PageFormatId
|
||||
onPageSetupApply: (setup: DocPageSetup) => void
|
||||
onShareClick?: () => void
|
||||
onRenameRequest?: () => void
|
||||
onFileMoved?: (newPath: string) => void
|
||||
onPurgeSidecarAndReimport?: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const router = useRouter()
|
||||
@ -192,11 +197,15 @@ export function useDocsFileMenu({
|
||||
onSecurityLimits: () => soon("Limites de sécurité"),
|
||||
onPageSetup: () => setPageSetupOpen(true),
|
||||
onPrint: () => window.print(),
|
||||
...(onPurgeSidecarAndReimport
|
||||
? { onPurgeSidecarAndReimport: () => void onPurgeSidecarAndReimport() }
|
||||
: {}),
|
||||
}),
|
||||
[
|
||||
downloadFormat,
|
||||
makeCopy,
|
||||
moveToTrash,
|
||||
onPurgeSidecarAndReimport,
|
||||
onRenameRequest,
|
||||
onShareClick,
|
||||
siblingNames,
|
||||
@ -260,8 +269,9 @@ export function useDocsFileMenu({
|
||||
<DocsPageSetupDialog
|
||||
open={pageSetupOpen}
|
||||
onOpenChange={setPageSetupOpen}
|
||||
pageFormatId={pageFormatId}
|
||||
onPageFormatChange={onPageFormatChange}
|
||||
pageSetup={pageSetup}
|
||||
fallbackFormatId={fallbackFormatId}
|
||||
onApply={onPageSetupApply}
|
||||
/>
|
||||
<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/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@ -3,9 +3,9 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --hostname 0.0.0.0 --webpack",
|
||||
"dev:turbo": "next dev --hostname 0.0.0.0",
|
||||
"dev:webpack": "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 --port 3004",
|
||||
"dev:webpack": "next dev --hostname 0.0.0.0 --port 3004 --webpack",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
@ -94,6 +94,7 @@
|
||||
"dayjs": "^1.11.20",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"fflate": "^0.8.3",
|
||||
"fuse.js": "^7.3.0",
|
||||
"idb": "^8.0.3",
|
||||
"input-otp": "1.4.2",
|
||||
|
||||
@ -227,6 +227,9 @@ importers:
|
||||
emoji-mart:
|
||||
specifier: ^5.6.0
|
||||
version: 5.6.0
|
||||
fflate:
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3
|
||||
fuse.js:
|
||||
specifier: ^7.3.0
|
||||
version: 7.3.0
|
||||
@ -2059,6 +2062,9 @@ packages:
|
||||
fflate@0.8.2:
|
||||
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
|
||||
|
||||
fflate@0.8.3:
|
||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||
|
||||
fraction.js@5.3.4:
|
||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||
|
||||
@ -4323,6 +4329,8 @@ snapshots:
|
||||
|
||||
fflate@0.8.2: {}
|
||||
|
||||
fflate@0.8.3: {}
|
||||
|
||||
fraction.js@5.3.4: {}
|
||||
|
||||
fsevents@2.3.2:
|
||||
|
||||
@ -31,6 +31,11 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ultidrive-richtext-editor img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.ultidrive-richtext-editor td,
|
||||
.ultidrive-richtext-editor th {
|
||||
border: 1px solid hsl(var(--border));
|
||||
@ -456,6 +461,10 @@ html.dark .docs-menu-item-icon {
|
||||
border: 1px solid #dadce0;
|
||||
}
|
||||
|
||||
.ultidrive-docs-page--imported-border {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.ultidrive-docs-page .ultidrive-richtext-editor {
|
||||
min-height: 100%;
|
||||
color: #000000;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user