This commit is contained in:
parent
8e420509a8
commit
2a7c153748
@ -999,12 +999,35 @@ html.dark :where(
|
|||||||
}
|
}
|
||||||
|
|
||||||
html.dark :where([data-slot='dialog-content'], [data-slot='sheet-content'])
|
html.dark :where([data-slot='dialog-content'], [data-slot='sheet-content'])
|
||||||
:where([data-slot='input'], textarea, [data-slot='select-trigger']) {
|
:where([data-slot='input'], textarea, [data-slot='select-trigger']:not([data-variant='ghost'])) {
|
||||||
background-color: var(--mail-surface-muted) !important;
|
background-color: var(--mail-surface-muted) !important;
|
||||||
border-color: var(--mail-border) !important;
|
border-color: var(--mail-border) !important;
|
||||||
color: var(--mail-text) !important;
|
color: var(--mail-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Drive dialogs — fond plus foncé que les modales mail */
|
||||||
|
html.dark .drive-dialog[data-slot='dialog-content'],
|
||||||
|
html.dark .drive-dialog[data-slot='sheet-content'] {
|
||||||
|
background-color: #292a2d !important;
|
||||||
|
border-color: #3c4043 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .drive-dialog[data-slot='dialog-content'] [data-slot='dialog-footer'],
|
||||||
|
html.dark .drive-dialog[data-slot='sheet-content'] [data-slot='sheet-footer'] {
|
||||||
|
background-color: #252628 !important;
|
||||||
|
border-color: #3c4043 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .drive-dialog [data-slot='select-trigger'][data-variant='ghost'] {
|
||||||
|
background-color: transparent !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .drive-dialog [data-slot='select-trigger'][data-variant='ghost']:hover,
|
||||||
|
html.dark .drive-dialog [data-slot='select-trigger'][data-variant='ghost'][data-state='open'] {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
html.dark :where([data-slot='dialog-content'], [data-slot='sheet-content']) fieldset.border {
|
html.dark :where([data-slot='dialog-content'], [data-slot='sheet-content']) fieldset.border {
|
||||||
border-color: var(--mail-border) !important;
|
border-color: var(--mail-border) !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,17 +7,21 @@ import { HocuspocusProvider } from "@hocuspocus/provider"
|
|||||||
import * as Y from "yjs"
|
import * as Y from "yjs"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { DocsChrome } from "@/components/drive/richtext/docs-chrome"
|
import { DocsChrome } from "@/components/drive/richtext/docs-chrome"
|
||||||
import { DocsPageView, DocsStatusBar } from "@/components/drive/richtext/docs-page-view"
|
import { DocsEditorWorkspace } from "@/components/drive/richtext/docs-editor-workspace"
|
||||||
|
import { DocsStatusBar } from "@/components/drive/richtext/docs-page-view"
|
||||||
import { DocsToolbar } from "@/components/drive/richtext/docs-toolbar"
|
import { DocsToolbar } from "@/components/drive/richtext/docs-toolbar"
|
||||||
import { buildRichTextExtensions, RICHTEXT_EDITOR_CLASS } from "@/lib/drive/richtext-extensions"
|
import { buildRichTextExtensions, RICHTEXT_EDITOR_CLASS } from "@/lib/drive/richtext-extensions"
|
||||||
import type { RichTextSaveStatus, RichTextSessionResponse } from "@/lib/drive/richtext-types"
|
import type { RichTextSaveStatus, RichTextSessionResponse } from "@/lib/drive/richtext-types"
|
||||||
import type { DriveShare, DriveFileInfo } from "@/lib/api/types"
|
import type { DriveShare, DriveFileInfo } from "@/lib/api/types"
|
||||||
import { useDocsEditMenu } from "@/lib/drive/use-docs-edit-menu"
|
import { useDocsEditMenu } from "@/lib/drive/use-docs-edit-menu"
|
||||||
import { useDocsFileMenu } from "@/lib/drive/use-docs-file-menu"
|
import { useDocsFileMenu } from "@/lib/drive/use-docs-file-menu"
|
||||||
|
import type { DocsViewMenuActions, DocsViewMenuState } from "@/components/drive/richtext/docs-view-menu"
|
||||||
import { useDocsViewSettings } from "@/lib/drive/docs-view-settings"
|
import { useDocsViewSettings } from "@/lib/drive/docs-view-settings"
|
||||||
|
import { useDocsKeyboardShortcutsStore } from "@/lib/stores/docs-keyboard-shortcuts-store"
|
||||||
import { useCollabPresence } from "@/lib/drive/use-collab-presence"
|
import { useCollabPresence } from "@/lib/drive/use-collab-presence"
|
||||||
import { apiClient } from "@/lib/api/client"
|
import { apiClient } from "@/lib/api/client"
|
||||||
import { driveDownloadApiPath } from "@/lib/api/drive-download"
|
import { driveDownloadApiPath } from "@/lib/api/drive-download"
|
||||||
|
import { buildRegionPatch } from "@/lib/drive/docs-header-footer-layout"
|
||||||
import {
|
import {
|
||||||
buildPageSetupForFormat,
|
buildPageSetupForFormat,
|
||||||
buildPageSetupFromDraft,
|
buildPageSetupFromDraft,
|
||||||
@ -27,11 +31,14 @@ import {
|
|||||||
import { readUserPageSetupDefaults } from "@/lib/drive/docs-page-defaults"
|
import { readUserPageSetupDefaults } from "@/lib/drive/docs-page-defaults"
|
||||||
import { isEmptyTipTapDoc } from "@/lib/drive/richtext-content"
|
import { isEmptyTipTapDoc } from "@/lib/drive/richtext-content"
|
||||||
import { importFileToTipTap } from "@/lib/drive/richtext-import"
|
import { importFileToTipTap } from "@/lib/drive/richtext-import"
|
||||||
|
import { migrateBase64ImagesInContent } from "@/lib/drive/docs-graphic-assets"
|
||||||
import { isUltidocPath } from "@/lib/drive/richtext-formats"
|
import { isUltidocPath } from "@/lib/drive/richtext-formats"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const SAVE_DEBOUNCE_MS = 2000
|
const SAVE_DEBOUNCE_MS = 2000
|
||||||
/** Align with Hocuspocus store debounce + buffer */
|
/** Align with Hocuspocus store debounce + buffer */
|
||||||
const COLLAB_SAVE_IDLE_MS = 2000
|
const COLLAB_SAVE_IDLE_MS = 2000
|
||||||
|
const PAGE_SETUP_DEBOUNCE_MS = 1500
|
||||||
|
|
||||||
export type RichTextDocsChromeProps = {
|
export type RichTextDocsChromeProps = {
|
||||||
title: string
|
title: string
|
||||||
@ -94,13 +101,32 @@ export function RichTextDocumentEditor({
|
|||||||
)
|
)
|
||||||
const [saveStatus, setSaveStatus] = useState<RichTextSaveStatus>("idle")
|
const [saveStatus, setSaveStatus] = useState<RichTextSaveStatus>("idle")
|
||||||
const [pageCount, setPageCount] = useState(1)
|
const [pageCount, setPageCount] = useState(1)
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const [regionEditor, setRegionEditor] = useState<import("@tiptap/react").Editor | null>(null)
|
||||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const saveIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const saveIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const pageSetupTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const documentPageSetupRef = useRef<DocPageSetup | null>(session.pageSetup ?? null)
|
||||||
const saveStatusRef = useRef<RichTextSaveStatus>("idle")
|
const saveStatusRef = useRef<RichTextSaveStatus>("idle")
|
||||||
const reloadAfterReimportRef = useRef(false)
|
const reloadAfterReimportRef = useRef(false)
|
||||||
|
const purgeReimportingRef = useRef(false)
|
||||||
|
const providerRef = useRef<HocuspocusProvider | null>(null)
|
||||||
|
|
||||||
const { settings, setPageFormatId, setZoom, toggleSpellcheck, toggleChromeCollapsed } =
|
const {
|
||||||
useDocsViewSettings()
|
settings,
|
||||||
|
setPageFormatId,
|
||||||
|
setZoom,
|
||||||
|
toggleSpellcheck,
|
||||||
|
toggleChromeCollapsed,
|
||||||
|
setEditorMode,
|
||||||
|
setCommentsDisplay,
|
||||||
|
toggleOutlineSidebarExpanded,
|
||||||
|
toggleShowLayout,
|
||||||
|
toggleShowRuler,
|
||||||
|
toggleShowEquationToolbar,
|
||||||
|
toggleShowNonPrintableChars,
|
||||||
|
} = useDocsViewSettings()
|
||||||
|
const shellRef = useRef<HTMLDivElement>(null)
|
||||||
const presenceUsers = useCollabPresence(provider, { name: userName, color: userColor })
|
const presenceUsers = useCollabPresence(provider, { name: userName, color: userColor })
|
||||||
const pageLayout = useMemo(
|
const pageLayout = useMemo(
|
||||||
() => resolveDocumentPageLayout(documentPageSetup, settings.pageFormatId),
|
() => resolveDocumentPageLayout(documentPageSetup, settings.pageFormatId),
|
||||||
@ -120,9 +146,6 @@ export function RichTextDocumentEditor({
|
|||||||
const persistPageSetup = useCallback(
|
const persistPageSetup = useCallback(
|
||||||
async (setup: DocPageSetup) => {
|
async (setup: DocPageSetup) => {
|
||||||
if (!editable) return
|
if (!editable) return
|
||||||
setDocumentPageSetup(setup)
|
|
||||||
if (setup.formatId) setPageFormatId(setup.formatId)
|
|
||||||
reportSaveStatus("saving")
|
|
||||||
try {
|
try {
|
||||||
const body = JSON.stringify({ pageSetup: setup })
|
const body = JSON.stringify({ pageSetup: setup })
|
||||||
if (session.saveUrl) {
|
if (session.saveUrl) {
|
||||||
@ -143,16 +166,80 @@ export function RichTextDocumentEditor({
|
|||||||
reportSaveStatus("error")
|
reportSaveStatus("error")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[editable, reportSaveStatus, session.canonicalPath, session.saveUrl, setPageFormatId]
|
[editable, reportSaveStatus, session.canonicalPath, session.saveUrl]
|
||||||
|
)
|
||||||
|
|
||||||
|
const schedulePageSetupPatch = useCallback(
|
||||||
|
(patch: Partial<DocPageSetup>, options?: { immediate?: boolean }) => {
|
||||||
|
const base =
|
||||||
|
documentPageSetupRef.current ??
|
||||||
|
buildPageSetupForFormat(settings.pageFormatId, null)
|
||||||
|
const next = { ...base, ...patch }
|
||||||
|
documentPageSetupRef.current = next
|
||||||
|
setDocumentPageSetup(next)
|
||||||
|
if (next.formatId) setPageFormatId(next.formatId)
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
const setup = documentPageSetupRef.current
|
||||||
|
if (!setup) return
|
||||||
|
void persistPageSetup(setup)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.immediate) {
|
||||||
|
if (pageSetupTimer.current) clearTimeout(pageSetupTimer.current)
|
||||||
|
if (saveStatusRef.current === "idle") {
|
||||||
|
reportSaveStatus("saving")
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveStatusRef.current === "idle") {
|
||||||
|
reportSaveStatus("saving")
|
||||||
|
}
|
||||||
|
if (pageSetupTimer.current) clearTimeout(pageSetupTimer.current)
|
||||||
|
pageSetupTimer.current = setTimeout(flush, PAGE_SETUP_DEBOUNCE_MS)
|
||||||
|
},
|
||||||
|
[persistPageSetup, reportSaveStatus, settings.pageFormatId, setPageFormatId]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handlePageFormatChange = useCallback(
|
const handlePageFormatChange = useCallback(
|
||||||
(formatId: typeof settings.pageFormatId) => {
|
(formatId: typeof settings.pageFormatId) => {
|
||||||
void persistPageSetup(buildPageSetupForFormat(formatId, documentPageSetup))
|
schedulePageSetupPatch(buildPageSetupForFormat(formatId, documentPageSetupRef.current), {
|
||||||
|
immediate: true,
|
||||||
|
})
|
||||||
},
|
},
|
||||||
[documentPageSetup, persistPageSetup, settings.pageFormatId]
|
[schedulePageSetupPatch, settings.pageFormatId]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleRegionContentChange = useCallback(
|
||||||
|
(
|
||||||
|
region: "header" | "footer",
|
||||||
|
content: Record<string, unknown>,
|
||||||
|
meta: { pageIndex: number; contentHeightPx: number },
|
||||||
|
options?: { immediate?: boolean }
|
||||||
|
) => {
|
||||||
|
const base =
|
||||||
|
documentPageSetupRef.current ?? buildPageSetupForFormat(settings.pageFormatId, null)
|
||||||
|
schedulePageSetupPatch(
|
||||||
|
buildRegionPatch(base, region, meta.pageIndex, content, meta.contentHeightPx),
|
||||||
|
options
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[schedulePageSetupPatch, settings.pageFormatId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlePageSetupPatch = useCallback(
|
||||||
|
(patch: Partial<DocPageSetup>, options?: { immediate?: boolean }) => {
|
||||||
|
schedulePageSetupPatch(patch, options)
|
||||||
|
},
|
||||||
|
[schedulePageSetupPatch]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
documentPageSetupRef.current = documentPageSetup
|
||||||
|
}, [documentPageSetup])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session.pageSetup) setDocumentPageSetup(session.pageSetup)
|
if (session.pageSetup) setDocumentPageSetup(session.pageSetup)
|
||||||
}, [session.pageSetup])
|
}, [session.pageSetup])
|
||||||
@ -172,12 +259,20 @@ export function RichTextDocumentEditor({
|
|||||||
|
|
||||||
const handlePageCountChange = useCallback((count: number) => {
|
const handlePageCountChange = useCallback((count: number) => {
|
||||||
setPageCount(count)
|
setPageCount(count)
|
||||||
|
setCurrentPage((page) => Math.min(page, count))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCurrentPageChange = useCallback((page: number) => {
|
||||||
|
setCurrentPage(page)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handlePurgeSidecarAndReimport = useCallback(async () => {
|
const handlePurgeSidecarAndReimport = useCallback(async () => {
|
||||||
if (!editable) return
|
if (!editable) return
|
||||||
const source = session.sourcePath || session.canonicalPath
|
if (!session.sourcePath) {
|
||||||
if (!source || isUltidocPath(source)) {
|
toast.error("Chemin source introuvable — ouvrez le document depuis le fichier DOCX")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isUltidocPath(session.sourcePath)) {
|
||||||
toast.error("Aucun fichier source à réimporter")
|
toast.error("Aucun fichier source à réimporter")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -190,15 +285,29 @@ export function RichTextDocumentEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
reportSaveStatus("saving")
|
reportSaveStatus("saving")
|
||||||
|
purgeReimportingRef.current = true
|
||||||
|
reloadAfterReimportRef.current = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
providerRef.current?.destroy()
|
||||||
|
providerRef.current = null
|
||||||
|
setProvider(null)
|
||||||
|
setCollabSynced(false)
|
||||||
|
|
||||||
|
if (ydocRef.current) {
|
||||||
|
ydocRef.current.destroy()
|
||||||
|
ydocRef.current = collaboration ? new Y.Doc() : null
|
||||||
|
}
|
||||||
|
|
||||||
await apiClient.delete(`/drive/files${session.canonicalPath}`)
|
await apiClient.delete(`/drive/files${session.canonicalPath}`)
|
||||||
setDocumentPageSetup(null)
|
setDocumentPageSetup(null)
|
||||||
setImportedContent(null)
|
setImportedContent(null)
|
||||||
reloadAfterReimportRef.current = collaboration
|
|
||||||
setImportDone(false)
|
setImportDone(false)
|
||||||
setContentImportPending(true)
|
setContentImportPending(true)
|
||||||
toast.success("Sidecar purgé — réimport en cours…")
|
toast.success("Sidecar purgé — réimport en cours…")
|
||||||
} catch {
|
} catch {
|
||||||
|
purgeReimportingRef.current = false
|
||||||
|
reloadAfterReimportRef.current = false
|
||||||
reportSaveStatus("error")
|
reportSaveStatus("error")
|
||||||
toast.error("Impossible de purger le sidecar")
|
toast.error("Impossible de purger le sidecar")
|
||||||
}
|
}
|
||||||
@ -218,6 +327,7 @@ export function RichTextDocumentEditor({
|
|||||||
return () => {
|
return () => {
|
||||||
if (saveTimer.current) clearTimeout(saveTimer.current)
|
if (saveTimer.current) clearTimeout(saveTimer.current)
|
||||||
if (saveIdleTimer.current) clearTimeout(saveIdleTimer.current)
|
if (saveIdleTimer.current) clearTimeout(saveIdleTimer.current)
|
||||||
|
if (pageSetupTimer.current) clearTimeout(pageSetupTimer.current)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@ -258,12 +368,18 @@ export function RichTextDocumentEditor({
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
reportSaveStatus("saving")
|
reportSaveStatus("saving")
|
||||||
try {
|
try {
|
||||||
const source = session.sourcePath || session.canonicalPath
|
const source = session.sourcePath
|
||||||
|
if (!source) {
|
||||||
|
throw new Error("Chemin source manquant pour le réimport")
|
||||||
|
}
|
||||||
const buf = fetchSourceBytes
|
const buf = fetchSourceBytes
|
||||||
? await fetchSourceBytes(source)
|
? await fetchSourceBytes(source)
|
||||||
: await (await apiClient.getBlob(driveDownloadApiPath(source))).arrayBuffer()
|
: await (await apiClient.getBlob(driveDownloadApiPath(source))).arrayBuffer()
|
||||||
const imported = await importFileToTipTap(source.split("/").pop() ?? "file.docx", buf)
|
const imported = await importFileToTipTap(source.split("/").pop() ?? "file.docx", buf)
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
|
if (isEmptyTipTapDoc(imported.content as Record<string, unknown>)) {
|
||||||
|
throw new Error("Le fichier source n'a produit aucun contenu importable")
|
||||||
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
source_path: source,
|
source_path: source,
|
||||||
content: imported.content,
|
content: imported.content,
|
||||||
@ -275,27 +391,41 @@ export function RichTextDocumentEditor({
|
|||||||
await apiClient.post("/richtext/import", payload)
|
await apiClient.post("/richtext/import", payload)
|
||||||
}
|
}
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setImportedContent(imported.content as Record<string, unknown>)
|
|
||||||
if (imported.pageSetup) setDocumentPageSetup(imported.pageSetup)
|
if (imported.pageSetup) setDocumentPageSetup(imported.pageSetup)
|
||||||
setContentImportPending(false)
|
|
||||||
setImportDone(true)
|
|
||||||
reportSaveStatus("saved")
|
|
||||||
if (reloadAfterReimportRef.current) {
|
if (reloadAfterReimportRef.current) {
|
||||||
reloadAfterReimportRef.current = false
|
reloadAfterReimportRef.current = false
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setImportedContent(imported.content as Record<string, unknown>)
|
||||||
|
setContentImportPending(false)
|
||||||
|
setImportDone(true)
|
||||||
|
purgeReimportingRef.current = false
|
||||||
|
reportSaveStatus("saved")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
purgeReimportingRef.current = false
|
||||||
|
reloadAfterReimportRef.current = false
|
||||||
|
reportSaveStatus("error")
|
||||||
|
toast.error(error instanceof Error ? error.message : "Réimport échoué")
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
if (!cancelled) reportSaveStatus("error")
|
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [contentImportPending, importDone, session, fetchSourceBytes, importApi, reportSaveStatus])
|
}, [
|
||||||
|
contentImportPending,
|
||||||
|
importDone,
|
||||||
|
session.sourcePath,
|
||||||
|
fetchSourceBytes,
|
||||||
|
importApi,
|
||||||
|
reportSaveStatus,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (purgeReimportingRef.current) return
|
||||||
if (!collaboration || !ydoc || !importDone) return
|
if (!collaboration || !ydoc || !importDone) return
|
||||||
|
|
||||||
setCollabSynced(false)
|
setCollabSynced(false)
|
||||||
@ -313,10 +443,12 @@ export function RichTextDocumentEditor({
|
|||||||
setCollabSynced(false)
|
setCollabSynced(false)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
providerRef.current = p
|
||||||
setProvider(p)
|
setProvider(p)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
p.destroy()
|
p.destroy()
|
||||||
|
providerRef.current = null
|
||||||
setProvider(null)
|
setProvider(null)
|
||||||
setCollabSynced(false)
|
setCollabSynced(false)
|
||||||
}
|
}
|
||||||
@ -330,20 +462,33 @@ export function RichTextDocumentEditor({
|
|||||||
reportSaveStatus("saving")
|
reportSaveStatus("saving")
|
||||||
}
|
}
|
||||||
saveTimer.current = setTimeout(() => {
|
saveTimer.current = setTimeout(() => {
|
||||||
const doc = { schemaVersion: 1, editor: "tiptap", content: json }
|
void (async () => {
|
||||||
const body = JSON.stringify(doc)
|
let content = json
|
||||||
const savePromise = session.saveUrl
|
try {
|
||||||
? fetch(session.saveUrl, {
|
content = await migrateBase64ImagesInContent(json, async ({ dataUrl }) => {
|
||||||
method: "PUT",
|
const res = await apiClient.post<{ assetId: string; url: string }>(
|
||||||
headers: { "Content-Type": "application/json" },
|
"/richtext/assets",
|
||||||
body,
|
{ path: session.canonicalPath, dataUrl }
|
||||||
}).then((res) => {
|
)
|
||||||
if (!res.ok) throw new Error("save failed")
|
return { assetId: res.assetId, url: res.url }
|
||||||
})
|
})
|
||||||
: apiClient.put("/richtext/save", { path: session.canonicalPath, document: json })
|
} catch {
|
||||||
void savePromise
|
/* keep base64 fallback */
|
||||||
.then(() => reportSaveStatus("saved"))
|
}
|
||||||
.catch(() => reportSaveStatus("error"))
|
const doc = { schemaVersion: 1, editor: "tiptap", content }
|
||||||
|
const body = JSON.stringify(doc)
|
||||||
|
const savePromise = session.saveUrl
|
||||||
|
? fetch(session.saveUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body,
|
||||||
|
}).then((res) => {
|
||||||
|
if (!res.ok) throw new Error("save failed")
|
||||||
|
})
|
||||||
|
: apiClient.put("/richtext/save", { path: session.canonicalPath, document: content })
|
||||||
|
await savePromise
|
||||||
|
reportSaveStatus("saved")
|
||||||
|
})().catch(() => reportSaveStatus("error"))
|
||||||
}, SAVE_DEBOUNCE_MS)
|
}, SAVE_DEBOUNCE_MS)
|
||||||
},
|
},
|
||||||
[collaboration, editable, reportSaveStatus, session.canonicalPath, session.saveUrl]
|
[collaboration, editable, reportSaveStatus, session.canonicalPath, session.saveUrl]
|
||||||
@ -416,7 +561,10 @@ export function RichTextDocumentEditor({
|
|||||||
editor,
|
editor,
|
||||||
pageSetup: documentPageSetup,
|
pageSetup: documentPageSetup,
|
||||||
fallbackFormatId: settings.pageFormatId,
|
fallbackFormatId: settings.pageFormatId,
|
||||||
onPageSetupApply: (setup) => void persistPageSetup(setup),
|
onPageSetupApply: (setup) => {
|
||||||
|
documentPageSetupRef.current = setup
|
||||||
|
schedulePageSetupPatch(setup, { immediate: true })
|
||||||
|
},
|
||||||
onPurgeSidecarAndReimport: () => void handlePurgeSidecarAndReimport(),
|
onPurgeSidecarAndReimport: () => void handlePurgeSidecarAndReimport(),
|
||||||
onShareClick: chrome?.onShareClick,
|
onShareClick: chrome?.onShareClick,
|
||||||
onRenameRequest: chrome?.onRenameRequest,
|
onRenameRequest: chrome?.onRenameRequest,
|
||||||
@ -429,6 +577,51 @@ export function RichTextDocumentEditor({
|
|||||||
disabled: !editable,
|
disabled: !editable,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleFullscreen = useCallback(() => {
|
||||||
|
const el = shellRef.current
|
||||||
|
if (!el) return
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
void document.exitFullscreen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void el.requestFullscreen?.()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const viewMenuState = useMemo<DocsViewMenuState>(
|
||||||
|
() => ({
|
||||||
|
editorMode: settings.editorMode,
|
||||||
|
commentsDisplay: settings.commentsDisplay,
|
||||||
|
showLayout: settings.showLayout,
|
||||||
|
showRuler: settings.showRuler,
|
||||||
|
showEquationToolbar: settings.showEquationToolbar,
|
||||||
|
showNonPrintableChars: settings.showNonPrintableChars,
|
||||||
|
}),
|
||||||
|
[settings]
|
||||||
|
)
|
||||||
|
|
||||||
|
const viewMenuActions = useMemo<DocsViewMenuActions>(
|
||||||
|
() => ({
|
||||||
|
onEditorModeChange: setEditorMode,
|
||||||
|
onCommentsDisplayChange: setCommentsDisplay,
|
||||||
|
onToggleOutlineSidebar: toggleOutlineSidebarExpanded,
|
||||||
|
onToggleShowLayout: toggleShowLayout,
|
||||||
|
onToggleShowRuler: toggleShowRuler,
|
||||||
|
onToggleShowEquationToolbar: toggleShowEquationToolbar,
|
||||||
|
onToggleShowNonPrintableChars: toggleShowNonPrintableChars,
|
||||||
|
onFullscreen: handleFullscreen,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
handleFullscreen,
|
||||||
|
setCommentsDisplay,
|
||||||
|
setEditorMode,
|
||||||
|
toggleOutlineSidebarExpanded,
|
||||||
|
toggleShowEquationToolbar,
|
||||||
|
toggleShowLayout,
|
||||||
|
toggleShowNonPrintableChars,
|
||||||
|
toggleShowRuler,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
const chromeProps = chrome
|
const chromeProps = chrome
|
||||||
? {
|
? {
|
||||||
...chrome,
|
...chrome,
|
||||||
@ -438,9 +631,34 @@ export function RichTextDocumentEditor({
|
|||||||
editMenuActions: editMenu.actions,
|
editMenuActions: editMenu.actions,
|
||||||
editMenuState: editMenu.state,
|
editMenuState: editMenu.state,
|
||||||
editMenuDisabled: editMenu.disabled,
|
editMenuDisabled: editMenu.disabled,
|
||||||
|
viewMenuActions,
|
||||||
|
viewMenuState,
|
||||||
|
viewMenuDisabled: false,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || editor.isDestroyed) return
|
||||||
|
const canEdit = editable && settings.editorMode !== "view"
|
||||||
|
editor.setEditable(canEdit)
|
||||||
|
}, [editor, editable, settings.editorMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const id = useDocsKeyboardShortcutsStore.getState().matchEvent(
|
||||||
|
event,
|
||||||
|
(definition) =>
|
||||||
|
definition.scope === "document" && definition.handler === "custom"
|
||||||
|
)
|
||||||
|
if (id === "view.showNonPrintable") {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleShowNonPrintableChars()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKeyDown)
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown)
|
||||||
|
}, [toggleShowNonPrintableChars])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor || collaboration || !importDone) return
|
if (!editor || collaboration || !importDone) return
|
||||||
|
|
||||||
@ -522,10 +740,6 @@ export function RichTextDocumentEditor({
|
|||||||
{...chromeProps}
|
{...chromeProps}
|
||||||
saveStatus={saveStatus}
|
saveStatus={saveStatus}
|
||||||
presenceUsers={presenceUsers}
|
presenceUsers={presenceUsers}
|
||||||
pageFormatId={activePageFormatId}
|
|
||||||
onPageFormatChange={handlePageFormatChange}
|
|
||||||
zoom={settings.zoom}
|
|
||||||
onZoomChange={setZoom}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||||
@ -536,37 +750,55 @@ export function RichTextDocumentEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-background">
|
<div
|
||||||
|
ref={shellRef}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 flex-1 flex-col bg-white dark:bg-background",
|
||||||
|
settings.outlineSidebarExpanded && "docs-outline-sidebar-expanded"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{chromeProps && !settings.chromeCollapsed ? (
|
{chromeProps && !settings.chromeCollapsed ? (
|
||||||
<DocsChrome
|
<DocsChrome
|
||||||
{...chromeProps}
|
{...chromeProps}
|
||||||
saveStatus={saveStatus}
|
saveStatus={saveStatus}
|
||||||
presenceUsers={presenceUsers}
|
presenceUsers={presenceUsers}
|
||||||
pageFormatId={activePageFormatId}
|
|
||||||
onPageFormatChange={handlePageFormatChange}
|
|
||||||
zoom={settings.zoom}
|
|
||||||
onZoomChange={setZoom}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{editable ? (
|
|
||||||
<DocsToolbar
|
|
||||||
editor={editor}
|
|
||||||
zoom={settings.zoom}
|
|
||||||
onZoomChange={setZoom}
|
|
||||||
spellcheck={settings.spellcheck}
|
|
||||||
onToggleSpellcheck={toggleSpellcheck}
|
|
||||||
showChromeToggle={Boolean(chrome)}
|
|
||||||
chromeCollapsed={settings.chromeCollapsed}
|
|
||||||
onToggleChromeCollapsed={toggleChromeCollapsed}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{chrome ? (
|
{chrome ? (
|
||||||
<DocsPageView
|
<DocsEditorWorkspace
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pageLayout={pageLayout}
|
pageLayout={pageLayout}
|
||||||
zoom={settings.zoom}
|
zoom={settings.zoom}
|
||||||
editable={editable}
|
editable={editable && settings.editorMode !== "view"}
|
||||||
|
showLayout={settings.showLayout}
|
||||||
|
showRuler={settings.showRuler}
|
||||||
|
showNonPrintableChars={settings.showNonPrintableChars}
|
||||||
|
editorMode={settings.editorMode}
|
||||||
|
outlineExpanded={settings.outlineSidebarExpanded}
|
||||||
|
onToggleOutline={toggleOutlineSidebarExpanded}
|
||||||
onPageCountChange={handlePageCountChange}
|
onPageCountChange={handlePageCountChange}
|
||||||
|
onCurrentPageChange={handleCurrentPageChange}
|
||||||
|
onRegionContentChange={handleRegionContentChange}
|
||||||
|
onPageSetupChange={handlePageSetupPatch}
|
||||||
|
onRegionEditorChange={setRegionEditor}
|
||||||
|
toolbarShellClassName={
|
||||||
|
settings.chromeCollapsed ? "docs-toolbar-shell--collapsed" : undefined
|
||||||
|
}
|
||||||
|
toolbar={
|
||||||
|
editable ? (
|
||||||
|
<DocsToolbar
|
||||||
|
editor={regionEditor ?? editor}
|
||||||
|
zoom={settings.zoom}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
spellcheck={settings.spellcheck}
|
||||||
|
onToggleSpellcheck={toggleSpellcheck}
|
||||||
|
showChromeToggle={Boolean(chrome)}
|
||||||
|
chromeCollapsed={settings.chromeCollapsed}
|
||||||
|
onToggleChromeCollapsed={toggleChromeCollapsed}
|
||||||
|
embedded
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="min-h-0 flex-1 overflow-auto">
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
@ -574,7 +806,11 @@ export function RichTextDocumentEditor({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{chrome ? (
|
{chrome ? (
|
||||||
<DocsStatusBar pageLayout={pageLayout} pageCount={pageCount} />
|
<DocsStatusBar
|
||||||
|
pageLayout={pageLayout}
|
||||||
|
pageCount={pageCount}
|
||||||
|
currentPage={currentPage}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
65
components/drive/richtext/docs-body-margin-masks.tsx
Normal file
65
components/drive/richtext/docs-body-margin-masks.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { DOCS_PAGE_GAP_PX } from "@/lib/drive/docs-page-layout-constants"
|
||||||
|
import type { DocPageLayout } from "@/lib/drive/doc-page-setup"
|
||||||
|
import {
|
||||||
|
pageFooterGeometry,
|
||||||
|
pageHeaderGeometry,
|
||||||
|
} from "@/lib/drive/docs-header-footer-layout"
|
||||||
|
|
||||||
|
/** Hides body text that bleeds into header/footer margin bands on each page. */
|
||||||
|
export function DocsBodyMarginMasks({
|
||||||
|
pageCount,
|
||||||
|
pageLayout,
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
pageColor,
|
||||||
|
pageRegionHeights,
|
||||||
|
}: {
|
||||||
|
pageCount: number
|
||||||
|
pageLayout: DocPageLayout
|
||||||
|
pageWidth: number
|
||||||
|
pageHeight: number
|
||||||
|
pageColor: string
|
||||||
|
pageRegionHeights?: Record<string, number>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: pageCount }, (_, index) => {
|
||||||
|
const pageTop = index * (pageHeight + DOCS_PAGE_GAP_PX)
|
||||||
|
const header = pageHeaderGeometry(pageLayout, pageTop, index)
|
||||||
|
const footer = pageFooterGeometry(pageLayout, pageTop, pageHeight, index)
|
||||||
|
const headerHeight =
|
||||||
|
pageRegionHeights?.[`header-${index}`] ?? header.zoneHeight
|
||||||
|
const footerHeight =
|
||||||
|
pageRegionHeights?.[`footer-${index}`] ?? footer.zoneHeight
|
||||||
|
const headerBottom = header.zoneTop + headerHeight
|
||||||
|
const footerTop = footer.zoneBottom - footerHeight
|
||||||
|
return (
|
||||||
|
<div key={`body-mask-${index}`} aria-hidden>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute z-[15]"
|
||||||
|
style={{
|
||||||
|
top: pageTop,
|
||||||
|
left: 0,
|
||||||
|
width: pageWidth,
|
||||||
|
height: headerBottom,
|
||||||
|
backgroundColor: pageColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute z-[15]"
|
||||||
|
style={{
|
||||||
|
top: footerTop,
|
||||||
|
left: 0,
|
||||||
|
width: pageWidth,
|
||||||
|
height: pageTop + pageHeight - footerTop,
|
||||||
|
backgroundColor: pageColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import { DocsLogoIcon } from "@/components/drive/richtext/docs-logo-icon"
|
|||||||
import { DocsMenubar } from "@/components/drive/richtext/docs-menubar"
|
import { DocsMenubar } from "@/components/drive/richtext/docs-menubar"
|
||||||
import type { DocsEditMenuActions, DocsEditMenuState } from "@/components/drive/richtext/docs-edit-menu"
|
import type { DocsEditMenuActions, DocsEditMenuState } from "@/components/drive/richtext/docs-edit-menu"
|
||||||
import type { DocsFileMenuActions } from "@/components/drive/richtext/docs-file-menu"
|
import type { DocsFileMenuActions } from "@/components/drive/richtext/docs-file-menu"
|
||||||
|
import type { DocsViewMenuActions, DocsViewMenuState } from "@/components/drive/richtext/docs-view-menu"
|
||||||
import { DocsMoveButton } from "@/components/drive/richtext/docs-move-button"
|
import { DocsMoveButton } from "@/components/drive/richtext/docs-move-button"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import type { DriveShare, DriveFileInfo } from "@/lib/api/types"
|
import type { DriveShare, DriveFileInfo } from "@/lib/api/types"
|
||||||
@ -19,7 +20,6 @@ import {
|
|||||||
resolveShareButtonIcon,
|
resolveShareButtonIcon,
|
||||||
type ShareButtonIcon,
|
type ShareButtonIcon,
|
||||||
} from "@/lib/drive/drive-share-button-state"
|
} from "@/lib/drive/drive-share-button-state"
|
||||||
import type { PageFormatId } from "@/lib/drive/page-formats"
|
|
||||||
import type { RichTextSaveStatus } from "@/lib/drive/richtext-types"
|
import type { RichTextSaveStatus } from "@/lib/drive/richtext-types"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@ -55,10 +55,9 @@ export function DocsChrome({
|
|||||||
showAccount = false,
|
showAccount = false,
|
||||||
saveStatus = "idle",
|
saveStatus = "idle",
|
||||||
presenceUsers = [],
|
presenceUsers = [],
|
||||||
pageFormatId,
|
viewMenuActions,
|
||||||
onPageFormatChange,
|
viewMenuState,
|
||||||
zoom,
|
viewMenuDisabled,
|
||||||
onZoomChange,
|
|
||||||
trailing,
|
trailing,
|
||||||
moveFile,
|
moveFile,
|
||||||
onFileMoved,
|
onFileMoved,
|
||||||
@ -82,10 +81,9 @@ export function DocsChrome({
|
|||||||
showAccount?: boolean
|
showAccount?: boolean
|
||||||
saveStatus?: RichTextSaveStatus
|
saveStatus?: RichTextSaveStatus
|
||||||
presenceUsers?: CollabPresenceUser[]
|
presenceUsers?: CollabPresenceUser[]
|
||||||
pageFormatId: PageFormatId
|
viewMenuActions?: DocsViewMenuActions
|
||||||
onPageFormatChange: (id: PageFormatId) => void
|
viewMenuState?: DocsViewMenuState
|
||||||
zoom: number
|
viewMenuDisabled?: boolean
|
||||||
onZoomChange: (zoom: number) => void
|
|
||||||
trailing?: ReactNode
|
trailing?: ReactNode
|
||||||
/** Propriétaire uniquement — affiche le bouton déplacer. */
|
/** Propriétaire uniquement — affiche le bouton déplacer. */
|
||||||
moveFile?: DriveFileInfo
|
moveFile?: DriveFileInfo
|
||||||
@ -164,10 +162,9 @@ export function DocsChrome({
|
|||||||
<div className="-mt-1 flex min-w-0 items-center overflow-x-auto overflow-y-visible">
|
<div className="-mt-1 flex min-w-0 items-center overflow-x-auto overflow-y-visible">
|
||||||
<DocsMenubar
|
<DocsMenubar
|
||||||
className="docs-menubar shrink-0"
|
className="docs-menubar shrink-0"
|
||||||
pageFormatId={pageFormatId}
|
viewMenuActions={viewMenuActions}
|
||||||
onPageFormatChange={onPageFormatChange}
|
viewMenuState={viewMenuState}
|
||||||
zoom={zoom}
|
viewMenuDisabled={viewMenuDisabled}
|
||||||
onZoomChange={onZoomChange}
|
|
||||||
fileMenuActions={fileMenuActions}
|
fileMenuActions={fileMenuActions}
|
||||||
fileMenuDisabled={fileMenuDisabled}
|
fileMenuDisabled={fileMenuDisabled}
|
||||||
editMenuActions={editMenuActions}
|
editMenuActions={editMenuActions}
|
||||||
|
|||||||
170
components/drive/richtext/docs-editor-workspace.tsx
Normal file
170
components/drive/richtext/docs-editor-workspace.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from "react"
|
||||||
|
import type { Editor } from "@tiptap/react"
|
||||||
|
import { DocsPageView } from "@/components/drive/richtext/docs-page-view"
|
||||||
|
import {
|
||||||
|
DocsRulerToolbarRow,
|
||||||
|
DocsRulersLeftRail,
|
||||||
|
} from "@/components/drive/richtext/docs-rulers-chrome"
|
||||||
|
import type { DocPageLayout } from "@/lib/drive/doc-page-setup"
|
||||||
|
import { DOCS_VERTICAL_RULER_WIDTH_PX } from "@/lib/drive/docs-page-layout-constants"
|
||||||
|
import { docsZoomToScale } from "@/lib/drive/docs-ruler-scale"
|
||||||
|
import { useDocsRulerSync } from "@/lib/drive/use-docs-ruler-sync"
|
||||||
|
import { useDocsRulerMarginDrag } from "@/components/drive/richtext/use-docs-ruler-margin-drag"
|
||||||
|
import { DocsRulerMarginDragTooltip } from "@/components/drive/richtext/docs-ruler-markers"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export function DocsEditorWorkspace({
|
||||||
|
editor,
|
||||||
|
pageLayout,
|
||||||
|
zoom,
|
||||||
|
editable,
|
||||||
|
showLayout,
|
||||||
|
showRuler,
|
||||||
|
showNonPrintableChars,
|
||||||
|
editorMode,
|
||||||
|
outlineExpanded,
|
||||||
|
onToggleOutline,
|
||||||
|
onPageCountChange,
|
||||||
|
onCurrentPageChange,
|
||||||
|
toolbar,
|
||||||
|
toolbarShellClassName,
|
||||||
|
onRegionContentChange,
|
||||||
|
onPageSetupChange,
|
||||||
|
onRegionEditorChange,
|
||||||
|
}: {
|
||||||
|
editor: Editor
|
||||||
|
pageLayout: DocPageLayout
|
||||||
|
zoom: number
|
||||||
|
editable: boolean
|
||||||
|
showLayout: boolean
|
||||||
|
showRuler: boolean
|
||||||
|
showNonPrintableChars: boolean
|
||||||
|
editorMode: "edit" | "suggest" | "view"
|
||||||
|
outlineExpanded?: boolean
|
||||||
|
onToggleOutline?: () => void
|
||||||
|
onPageCountChange?: (count: number) => void
|
||||||
|
onCurrentPageChange?: (page: number) => void
|
||||||
|
toolbar?: ReactNode
|
||||||
|
toolbarShellClassName?: string
|
||||||
|
onRegionContentChange?: (
|
||||||
|
region: "header" | "footer",
|
||||||
|
content: Record<string, unknown>,
|
||||||
|
meta: { pageIndex: number; contentHeightPx: number }
|
||||||
|
) => void
|
||||||
|
onPageSetupChange?: (
|
||||||
|
patch: Partial<import("@/lib/drive/doc-page-setup").DocPageSetup>,
|
||||||
|
options?: { immediate?: boolean }
|
||||||
|
) => void
|
||||||
|
onRegionEditorChange?: (editor: import("@tiptap/react").Editor | null) => void
|
||||||
|
}) {
|
||||||
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
|
const rulerTrackRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [pageCount, setPageCount] = useState(1)
|
||||||
|
const [narrowViewport, setNarrowViewport] = useState(false)
|
||||||
|
|
||||||
|
const rulersVisible = showLayout && showRuler
|
||||||
|
const scale = docsZoomToScale(zoom)
|
||||||
|
const showToolbarShell = Boolean(toolbar) || rulersVisible
|
||||||
|
const marginsEditable = editable && editorMode !== "view"
|
||||||
|
|
||||||
|
const {
|
||||||
|
pageLayoutWithMargins,
|
||||||
|
beginMarginDrag,
|
||||||
|
moveMarginDrag,
|
||||||
|
endMarginDrag,
|
||||||
|
dragTooltip,
|
||||||
|
} = useDocsRulerMarginDrag({
|
||||||
|
pageLayout,
|
||||||
|
editable: marginsEditable,
|
||||||
|
onPageSetupChange,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rulerSync = useDocsRulerSync({
|
||||||
|
canvasRef,
|
||||||
|
rulerTrackRef,
|
||||||
|
editor,
|
||||||
|
pageLayout: pageLayoutWithMargins,
|
||||||
|
zoom,
|
||||||
|
pageCount,
|
||||||
|
narrowViewport,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onCurrentPageChange?.(rulerSync.currentPage + 1)
|
||||||
|
}, [onCurrentPageChange, rulerSync.currentPage])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="docs-editor-workspace flex min-h-0 flex-1 flex-col">
|
||||||
|
<DocsRulerMarginDragTooltip tooltip={dragTooltip} />
|
||||||
|
{showToolbarShell ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"docs-toolbar-shell shrink-0",
|
||||||
|
toolbarShellClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{toolbar}
|
||||||
|
{rulersVisible ? (
|
||||||
|
<DocsRulerToolbarRow
|
||||||
|
pageLayout={pageLayoutWithMargins}
|
||||||
|
scale={scale}
|
||||||
|
rulerSync={rulerSync}
|
||||||
|
rulerTrackRef={rulerTrackRef}
|
||||||
|
outlineExpanded={outlineExpanded}
|
||||||
|
onToggleOutline={onToggleOutline}
|
||||||
|
editable={marginsEditable}
|
||||||
|
onMarginDragStart={beginMarginDrag}
|
||||||
|
onMarginDrag={moveMarginDrag}
|
||||||
|
onMarginDragEnd={endMarginDrag}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||||
|
{rulersVisible ? (
|
||||||
|
<DocsRulersLeftRail
|
||||||
|
pageLayout={pageLayoutWithMargins}
|
||||||
|
scale={scale}
|
||||||
|
rulerSync={rulerSync}
|
||||||
|
editable={marginsEditable}
|
||||||
|
onMarginDragStart={beginMarginDrag}
|
||||||
|
onMarginDrag={moveMarginDrag}
|
||||||
|
onMarginDragEnd={endMarginDrag}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex h-full min-h-0 flex-col"
|
||||||
|
style={
|
||||||
|
rulersVisible
|
||||||
|
? { paddingLeft: DOCS_VERTICAL_RULER_WIDTH_PX }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DocsPageView
|
||||||
|
editor={editor}
|
||||||
|
pageLayout={pageLayoutWithMargins}
|
||||||
|
zoom={zoom}
|
||||||
|
editable={editable}
|
||||||
|
showLayout={showLayout}
|
||||||
|
showRuler={false}
|
||||||
|
showNonPrintableChars={showNonPrintableChars}
|
||||||
|
editorMode={editorMode}
|
||||||
|
canvasRef={canvasRef}
|
||||||
|
onPageCountChange={(count) => {
|
||||||
|
setPageCount(count)
|
||||||
|
onPageCountChange?.(count)
|
||||||
|
}}
|
||||||
|
onNarrowViewportChange={setNarrowViewport}
|
||||||
|
onRegionContentChange={onRegionContentChange}
|
||||||
|
onPageSetupChange={onPageSetupChange}
|
||||||
|
onRegionEditorChange={onRegionEditorChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
components/drive/richtext/docs-exclusive-menu-sub.tsx
Normal file
59
components/drive/richtext/docs-exclusive-menu-sub.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useId,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react"
|
||||||
|
import { MenubarSub } from "@/components/ui/menubar"
|
||||||
|
|
||||||
|
type ExclusiveMenuSubContextValue = {
|
||||||
|
openId: string | null
|
||||||
|
setOpenId: (id: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExclusiveMenuSubContext = createContext<ExclusiveMenuSubContextValue | null>(null)
|
||||||
|
|
||||||
|
/** Ensures only one MenubarSub stays open while hovering across sibling sub-triggers. */
|
||||||
|
export function DocsExclusiveMenuSubRoot({ children }: { children: ReactNode }) {
|
||||||
|
const [openId, setOpenId] = useState<string | null>(null)
|
||||||
|
return (
|
||||||
|
<ExclusiveMenuSubContext.Provider value={{ openId, setOpenId }}>
|
||||||
|
{children}
|
||||||
|
</ExclusiveMenuSubContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocsExclusiveMenuSub({
|
||||||
|
id,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const ctx = useContext(ExclusiveMenuSubContext)
|
||||||
|
const fallbackId = useId()
|
||||||
|
const subId = id || fallbackId
|
||||||
|
|
||||||
|
const open = ctx?.openId === subId
|
||||||
|
const onOpenChange = useCallback(
|
||||||
|
(next: boolean) => {
|
||||||
|
if (!ctx) return
|
||||||
|
ctx.setOpenId(next ? subId : null)
|
||||||
|
},
|
||||||
|
[ctx, subId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!ctx) {
|
||||||
|
return <MenubarSub>{children}</MenubarSub>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenubarSub open={open} onOpenChange={onOpenChange}>
|
||||||
|
{children}
|
||||||
|
</MenubarSub>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -25,11 +25,14 @@ import {
|
|||||||
MenubarItem,
|
MenubarItem,
|
||||||
MenubarMenu,
|
MenubarMenu,
|
||||||
MenubarSeparator,
|
MenubarSeparator,
|
||||||
MenubarSub,
|
|
||||||
MenubarSubContent,
|
MenubarSubContent,
|
||||||
MenubarSubTrigger,
|
MenubarSubTrigger,
|
||||||
MenubarTrigger,
|
MenubarTrigger,
|
||||||
} from "@/components/ui/menubar"
|
} from "@/components/ui/menubar"
|
||||||
|
import {
|
||||||
|
DocsExclusiveMenuSub,
|
||||||
|
DocsExclusiveMenuSubRoot,
|
||||||
|
} from "@/components/drive/richtext/docs-exclusive-menu-sub"
|
||||||
import { DOCS_MENUBAR_CONTENT_PROPS } from "@/components/drive/richtext/docs-menubar-props"
|
import { DOCS_MENUBAR_CONTENT_PROPS } from "@/components/drive/richtext/docs-menubar-props"
|
||||||
import { DocsLogoIcon } from "@/components/drive/richtext/docs-logo-icon"
|
import { DocsLogoIcon } from "@/components/drive/richtext/docs-logo-icon"
|
||||||
import { DocsMenuShortcut } from "@/components/drive/richtext/docs-menu-shortcut"
|
import { DocsMenuShortcut } from "@/components/drive/richtext/docs-menu-shortcut"
|
||||||
@ -81,7 +84,8 @@ export function DocsFileMenu({
|
|||||||
className="docs-menu-content min-w-[280px] overflow-visible"
|
className="docs-menu-content min-w-[280px] overflow-visible"
|
||||||
data-docs-menu-surface
|
data-docs-menu-surface
|
||||||
>
|
>
|
||||||
<MenubarSub>
|
<DocsExclusiveMenuSubRoot>
|
||||||
|
<DocsExclusiveMenuSub id="new">
|
||||||
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
|
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
|
||||||
<MenuIcon>
|
<MenuIcon>
|
||||||
<FileText className="size-4" />
|
<FileText className="size-4" />
|
||||||
@ -106,7 +110,7 @@ export function DocsFileMenu({
|
|||||||
À partir de la galerie de modèles
|
À partir de la galerie de modèles
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
</MenubarSubContent>
|
</MenubarSubContent>
|
||||||
</MenubarSub>
|
</DocsExclusiveMenuSub>
|
||||||
|
|
||||||
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onOpen}>
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onOpen}>
|
||||||
<MenuIcon>
|
<MenuIcon>
|
||||||
@ -125,7 +129,7 @@ export function DocsFileMenu({
|
|||||||
|
|
||||||
<MenubarSeparator />
|
<MenubarSeparator />
|
||||||
|
|
||||||
<MenubarSub>
|
<DocsExclusiveMenuSub id="share">
|
||||||
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
|
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
|
||||||
<MenuIcon>
|
<MenuIcon>
|
||||||
<UserPlus className="size-4" />
|
<UserPlus className="size-4" />
|
||||||
@ -146,9 +150,9 @@ export function DocsFileMenu({
|
|||||||
Publier sur le Web
|
Publier sur le Web
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
</MenubarSubContent>
|
</MenubarSubContent>
|
||||||
</MenubarSub>
|
</DocsExclusiveMenuSub>
|
||||||
|
|
||||||
<MenubarSub>
|
<DocsExclusiveMenuSub id="email">
|
||||||
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
|
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
|
||||||
<MenuIcon>
|
<MenuIcon>
|
||||||
<Mail className="size-4" />
|
<Mail className="size-4" />
|
||||||
@ -170,9 +174,9 @@ export function DocsFileMenu({
|
|||||||
Brouillon d'e-mail
|
Brouillon d'e-mail
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
</MenubarSubContent>
|
</MenubarSubContent>
|
||||||
</MenubarSub>
|
</DocsExclusiveMenuSub>
|
||||||
|
|
||||||
<MenubarSub>
|
<DocsExclusiveMenuSub id="download">
|
||||||
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
|
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
|
||||||
<MenuIcon>
|
<MenuIcon>
|
||||||
<Download className="size-4" />
|
<Download className="size-4" />
|
||||||
@ -191,7 +195,7 @@ export function DocsFileMenu({
|
|||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
))}
|
))}
|
||||||
</MenubarSubContent>
|
</MenubarSubContent>
|
||||||
</MenubarSub>
|
</DocsExclusiveMenuSub>
|
||||||
|
|
||||||
<MenubarSeparator />
|
<MenubarSeparator />
|
||||||
|
|
||||||
@ -229,7 +233,7 @@ export function DocsFileMenu({
|
|||||||
|
|
||||||
<MenubarSeparator />
|
<MenubarSeparator />
|
||||||
|
|
||||||
<MenubarSub>
|
<DocsExclusiveMenuSub id="version-history">
|
||||||
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
|
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
|
||||||
<MenuIcon>
|
<MenuIcon>
|
||||||
<History className="size-4" />
|
<History className="size-4" />
|
||||||
@ -252,7 +256,7 @@ export function DocsFileMenu({
|
|||||||
Afficher l'historique des versions
|
Afficher l'historique des versions
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
</MenubarSubContent>
|
</MenubarSubContent>
|
||||||
</MenubarSub>
|
</DocsExclusiveMenuSub>
|
||||||
|
|
||||||
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onToggleOffline}>
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onToggleOffline}>
|
||||||
<MenuIcon>
|
<MenuIcon>
|
||||||
@ -307,6 +311,7 @@ export function DocsFileMenu({
|
|||||||
Imprimer
|
Imprimer
|
||||||
<DocsMenuShortcut shortcutId="file.print" />
|
<DocsMenuShortcut shortcutId="file.print" />
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
|
</DocsExclusiveMenuSubRoot>
|
||||||
</MenubarContent>
|
</MenubarContent>
|
||||||
</MenubarMenu>
|
</MenubarMenu>
|
||||||
)
|
)
|
||||||
|
|||||||
136
components/drive/richtext/docs-graphic-context-menu.tsx
Normal file
136
components/drive/richtext/docs-graphic-context-menu.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { Editor } from "@tiptap/react"
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "@/components/ui/context-menu"
|
||||||
|
import {
|
||||||
|
DOCS_GRAPHIC_WRAP_LABELS,
|
||||||
|
parseGraphicAttrs,
|
||||||
|
type DocsGraphicWrap,
|
||||||
|
} from "@/lib/drive/docs-graphic-types"
|
||||||
|
|
||||||
|
export function DocsGraphicContextMenu({
|
||||||
|
editor,
|
||||||
|
children,
|
||||||
|
onCrop,
|
||||||
|
onOpenOptions,
|
||||||
|
onReplaceImage,
|
||||||
|
}: {
|
||||||
|
editor: Editor
|
||||||
|
children: React.ReactNode
|
||||||
|
onCrop?: () => void
|
||||||
|
onOpenOptions?: () => void
|
||||||
|
onReplaceImage?: () => void
|
||||||
|
}) {
|
||||||
|
const active =
|
||||||
|
editor.isActive("docsGraphic") || editor.isActive("docsInlineGraphic")
|
||||||
|
if (!active) return <>{children}</>
|
||||||
|
|
||||||
|
const name = editor.isActive("docsInlineGraphic") ? "docsInlineGraphic" : "docsGraphic"
|
||||||
|
const attrs = parseGraphicAttrs(editor.getAttributes(name) as Record<string, unknown>)
|
||||||
|
const isImage = attrs.graphicType === "image"
|
||||||
|
|
||||||
|
const applyWrap = (wrap: DocsGraphicWrap) => {
|
||||||
|
editor.chain().focus().setDocsGraphicWrap(wrap).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadImage = () => {
|
||||||
|
if (!attrs.src) return
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.href = attrs.src
|
||||||
|
link.download = "image"
|
||||||
|
link.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="min-w-52">
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => document.execCommand("cut")}
|
||||||
|
>
|
||||||
|
Couper
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => document.execCommand("copy")}
|
||||||
|
>
|
||||||
|
Copier
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => document.execCommand("paste")}
|
||||||
|
>
|
||||||
|
Coller
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => editor.chain().focus().deleteSelection().run()}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
{isImage ? (
|
||||||
|
<>
|
||||||
|
<ContextMenuItem onClick={onReplaceImage}>Remplacer l'image…</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={onCrop}>Recadrer</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={onOpenOptions}>Options image…</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
const alt = window.prompt("Texte alternatif", attrs.alt)
|
||||||
|
if (alt != null) editor.chain().focus().updateDocsGraphic({ alt }).run()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Texte alternatif…
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={downloadImage} disabled={!attrs.src}>
|
||||||
|
Télécharger l'image
|
||||||
|
</ContextMenuItem>
|
||||||
|
</>
|
||||||
|
) : attrs.graphicType === "shape" ? (
|
||||||
|
<>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
const fill = window.prompt("Couleur de remplissage", attrs.fill)
|
||||||
|
if (fill) editor.chain().focus().updateDocsGraphic({ fill }).run()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Modifier le remplissage…
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
const stroke = window.prompt("Couleur du contour", attrs.stroke)
|
||||||
|
if (stroke) editor.chain().focus().updateDocsGraphic({ stroke }).run()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Modifier le contour…
|
||||||
|
</ContextMenuItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuSub>
|
||||||
|
<ContextMenuSubTrigger>Habillage texte</ContextMenuSubTrigger>
|
||||||
|
<ContextMenuSubContent>
|
||||||
|
{(Object.keys(DOCS_GRAPHIC_WRAP_LABELS) as DocsGraphicWrap[]).map((wrap) => (
|
||||||
|
<ContextMenuItem key={wrap} onClick={() => applyWrap(wrap)}>
|
||||||
|
{DOCS_GRAPHIC_WRAP_LABELS[wrap]}
|
||||||
|
</ContextMenuItem>
|
||||||
|
))}
|
||||||
|
</ContextMenuSubContent>
|
||||||
|
</ContextMenuSub>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem onClick={() => editor.chain().focus().bringDocsGraphicForward().run()}>
|
||||||
|
Avancer
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => editor.chain().focus().sendDocsGraphicBackward().run()}>
|
||||||
|
Reculer
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
components/drive/richtext/docs-graphic-crop-overlay.tsx
Normal file
143
components/drive/richtext/docs-graphic-crop-overlay.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef } from "react"
|
||||||
|
import type { DocsGraphicAttrs } from "@/lib/drive/docs-graphic-types"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"] as const
|
||||||
|
type Handle = (typeof HANDLES)[number]
|
||||||
|
|
||||||
|
const HANDLE_CLASS: Record<Handle, string> = {
|
||||||
|
nw: "left-0 top-0 cursor-nwse-resize",
|
||||||
|
n: "left-1/2 top-0 -translate-x-1/2 cursor-ns-resize",
|
||||||
|
ne: "right-0 top-0 cursor-nesw-resize",
|
||||||
|
e: "right-0 top-1/2 -translate-y-1/2 cursor-ew-resize",
|
||||||
|
se: "right-0 bottom-0 cursor-nwse-resize",
|
||||||
|
s: "left-1/2 bottom-0 -translate-x-1/2 cursor-ns-resize",
|
||||||
|
sw: "left-0 bottom-0 cursor-nesw-resize",
|
||||||
|
w: "left-0 top-1/2 -translate-y-1/2 cursor-ew-resize",
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(v: number): number {
|
||||||
|
return Math.min(1, Math.max(0, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeCrop(
|
||||||
|
handle: Handle,
|
||||||
|
crop: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">,
|
||||||
|
dxNorm: number,
|
||||||
|
dyNorm: number
|
||||||
|
): Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight"> {
|
||||||
|
let { cropX, cropY, cropWidth, cropHeight } = crop
|
||||||
|
if (handle.includes("e")) cropWidth = clamp01(cropWidth + dxNorm)
|
||||||
|
if (handle.includes("w")) {
|
||||||
|
cropWidth = clamp01(cropWidth - dxNorm)
|
||||||
|
cropX = clamp01(cropX + dxNorm)
|
||||||
|
}
|
||||||
|
if (handle.includes("s")) cropHeight = clamp01(cropHeight + dyNorm)
|
||||||
|
if (handle.includes("n")) {
|
||||||
|
cropHeight = clamp01(cropHeight - dyNorm)
|
||||||
|
cropY = clamp01(cropY + dyNorm)
|
||||||
|
}
|
||||||
|
cropWidth = Math.max(0.05, cropWidth)
|
||||||
|
cropHeight = Math.max(0.05, cropHeight)
|
||||||
|
return { cropX, cropY, cropWidth, cropHeight }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocsGraphicCropOverlay({
|
||||||
|
attrs,
|
||||||
|
frameWidth,
|
||||||
|
frameHeight,
|
||||||
|
onChange,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
attrs: DocsGraphicAttrs
|
||||||
|
frameWidth: number
|
||||||
|
frameHeight: number
|
||||||
|
onChange: (patch: Partial<DocsGraphicAttrs>) => void
|
||||||
|
onDone: () => void
|
||||||
|
}) {
|
||||||
|
const dragRef = useRef<{
|
||||||
|
handle: Handle
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
origin: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const onHandleDown = useCallback(
|
||||||
|
(handle: Handle, event: React.PointerEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
|
||||||
|
dragRef.current = {
|
||||||
|
handle,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
origin: {
|
||||||
|
cropX: attrs.cropX,
|
||||||
|
cropY: attrs.cropY,
|
||||||
|
cropWidth: attrs.cropWidth,
|
||||||
|
cropHeight: attrs.cropHeight,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[attrs.cropHeight, attrs.cropWidth, attrs.cropX, attrs.cropY]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMove = (event: PointerEvent) => {
|
||||||
|
if (!dragRef.current) return
|
||||||
|
const { handle, startX, startY, origin } = dragRef.current
|
||||||
|
const dxNorm = (event.clientX - startX) / Math.max(frameWidth, 1)
|
||||||
|
const dyNorm = (event.clientY - startY) / Math.max(frameHeight, 1)
|
||||||
|
onChange(resizeCrop(handle, origin, dxNorm, dyNorm))
|
||||||
|
}
|
||||||
|
const onUp = () => {
|
||||||
|
dragRef.current = null
|
||||||
|
}
|
||||||
|
window.addEventListener("pointermove", onMove)
|
||||||
|
window.addEventListener("pointerup", onUp)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointermove", onMove)
|
||||||
|
window.removeEventListener("pointerup", onUp)
|
||||||
|
}
|
||||||
|
}, [frameHeight, frameWidth, onChange])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") onDone()
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey)
|
||||||
|
return () => window.removeEventListener("keydown", onKey)
|
||||||
|
}, [onDone])
|
||||||
|
|
||||||
|
const left = `${attrs.cropX * 100}%`
|
||||||
|
const top = `${attrs.cropY * 100}%`
|
||||||
|
const width = `${attrs.cropWidth * 100}%`
|
||||||
|
const height = `${attrs.cropHeight * 100}%`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="docs-graphic-crop absolute inset-0 z-30" aria-label="Recadrage">
|
||||||
|
<div className="absolute inset-0 bg-black/40" />
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute border-2 border-white shadow-[0_0_0_1px_#1a73e8]",
|
||||||
|
attrs.cropShape === "ellipse" && "rounded-full"
|
||||||
|
)}
|
||||||
|
style={{ left, top, width, height }}
|
||||||
|
>
|
||||||
|
{HANDLES.map((handle) => (
|
||||||
|
<span
|
||||||
|
key={handle}
|
||||||
|
role="presentation"
|
||||||
|
className={cn(
|
||||||
|
"absolute z-40 size-2.5 rounded-full border border-white bg-[#1a73e8] shadow",
|
||||||
|
HANDLE_CLASS[handle]
|
||||||
|
)}
|
||||||
|
onPointerDown={(event) => onHandleDown(handle, event)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
470
components/drive/richtext/docs-graphic-node-view.tsx
Normal file
470
components/drive/richtext/docs-graphic-node-view.tsx
Normal file
@ -0,0 +1,470 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { memo, useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import { NodeViewWrapper, type NodeViewProps } from "@tiptap/react"
|
||||||
|
import {
|
||||||
|
computeGraphicLayoutStyle,
|
||||||
|
resizeWithHandle,
|
||||||
|
type ResizeHandle,
|
||||||
|
RESIZE_HANDLES,
|
||||||
|
} from "@/lib/drive/docs-graphic-layout"
|
||||||
|
import {
|
||||||
|
computeCropImageStyle,
|
||||||
|
parseGraphicAttrs,
|
||||||
|
type DocsGraphicAttrs,
|
||||||
|
type DocsShapeType,
|
||||||
|
} from "@/lib/drive/docs-graphic-types"
|
||||||
|
import { DocsGraphicCropOverlay } from "@/components/drive/richtext/docs-graphic-crop-overlay"
|
||||||
|
import { DocsGraphicContextMenu } from "@/components/drive/richtext/docs-graphic-context-menu"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ShapePreview({
|
||||||
|
shapeType,
|
||||||
|
fill,
|
||||||
|
stroke,
|
||||||
|
strokeWidth,
|
||||||
|
}: {
|
||||||
|
shapeType: DocsShapeType
|
||||||
|
fill: string
|
||||||
|
stroke: string
|
||||||
|
strokeWidth: number
|
||||||
|
}) {
|
||||||
|
if (shapeType === "ellipse") {
|
||||||
|
return (
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 100 100" aria-hidden>
|
||||||
|
<ellipse cx="50" cy="50" rx="46" ry="40" fill={fill} stroke={stroke} strokeWidth={strokeWidth} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (shapeType === "line") {
|
||||||
|
return (
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 100 100" aria-hidden>
|
||||||
|
<line x1="8" y1="50" x2="92" y2="50" stroke={stroke} strokeWidth={strokeWidth + 1} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (shapeType === "arrow") {
|
||||||
|
return (
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 100 100" aria-hidden>
|
||||||
|
<line x1="10" y1="50" x2="78" y2="50" stroke={stroke} strokeWidth={strokeWidth + 1} />
|
||||||
|
<polygon points="78,38 92,50 78,62" fill={stroke} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 100 100" aria-hidden>
|
||||||
|
<rect x="6" y="10" width="88" height="80" rx="4" fill={fill} stroke={stroke} strokeWidth={strokeWidth} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GraphicContent({ attrs }: { attrs: DocsGraphicAttrs }) {
|
||||||
|
if (attrs.graphicType === "image") {
|
||||||
|
if (!attrs.src) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-[#f1f3f4] text-xs text-[#5f6368]">
|
||||||
|
Image
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const cropStyle = computeCropImageStyle(attrs)
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={attrs.src}
|
||||||
|
alt={attrs.alt || ""}
|
||||||
|
draggable={false}
|
||||||
|
className="block h-full w-full"
|
||||||
|
style={{
|
||||||
|
objectFit: Object.keys(cropStyle.img).length ? undefined : "contain",
|
||||||
|
...cropStyle.img,
|
||||||
|
clipPath: cropStyle.clipPath,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.graphicType === "gradient") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h-full w-full"
|
||||||
|
style={{ background: attrs.gradientCss }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ShapePreview
|
||||||
|
shapeType={attrs.shapeType}
|
||||||
|
fill={attrs.fill}
|
||||||
|
stroke={attrs.stroke}
|
||||||
|
strokeWidth={attrs.strokeWidth}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResizeHandleBtn({
|
||||||
|
handle,
|
||||||
|
onPointerDown,
|
||||||
|
}: {
|
||||||
|
handle: ResizeHandle
|
||||||
|
onPointerDown: (handle: ResizeHandle, event: React.PointerEvent) => void
|
||||||
|
}) {
|
||||||
|
const posClass: Record<ResizeHandle, string> = {
|
||||||
|
nw: "left-0 top-0 cursor-nwse-resize",
|
||||||
|
n: "left-1/2 top-0 -translate-x-1/2 cursor-ns-resize",
|
||||||
|
ne: "right-0 top-0 cursor-nesw-resize",
|
||||||
|
e: "right-0 top-1/2 -translate-y-1/2 cursor-ew-resize",
|
||||||
|
se: "right-0 bottom-0 cursor-nwse-resize",
|
||||||
|
s: "left-1/2 bottom-0 -translate-x-1/2 cursor-ns-resize",
|
||||||
|
sw: "left-0 bottom-0 cursor-nesw-resize",
|
||||||
|
w: "left-0 top-1/2 -translate-y-1/2 cursor-ew-resize",
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role="presentation"
|
||||||
|
className={cn(
|
||||||
|
"docs-graphic-handle absolute z-20 size-2.5 rounded-full border border-white bg-[#1a73e8] shadow",
|
||||||
|
posClass[handle]
|
||||||
|
)}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
onPointerDown(handle, event)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RotationHandle({
|
||||||
|
onPointerDown,
|
||||||
|
}: {
|
||||||
|
onPointerDown: (event: React.PointerEvent) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role="presentation"
|
||||||
|
className="docs-graphic-rotate-handle absolute left-1/2 top-0 z-20 flex -translate-x-1/2 -translate-y-[calc(100%+8px)] cursor-grab items-center justify-center"
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
onPointerDown(event)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="size-2.5 rounded-full border border-white bg-[#1a73e8] shadow" />
|
||||||
|
<span className="absolute top-full h-2 w-px bg-[#1a73e8]" aria-hidden />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocsGraphicNodeViewInner({
|
||||||
|
node,
|
||||||
|
updateAttributes,
|
||||||
|
selected,
|
||||||
|
editor,
|
||||||
|
getPos,
|
||||||
|
extension,
|
||||||
|
}: NodeViewProps) {
|
||||||
|
const attrs = parseGraphicAttrs(node.attrs as Record<string, unknown>)
|
||||||
|
const layout = computeGraphicLayoutStyle(attrs)
|
||||||
|
const editable = editor.isEditable
|
||||||
|
const inline = extension.name === "docsInlineGraphic"
|
||||||
|
const replaceInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const dragRef = useRef<{ startX: number; startY: number; originX: number; originY: number } | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
const pendingDragRef = useRef<{
|
||||||
|
pointerId: number
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
originX: number
|
||||||
|
originY: number
|
||||||
|
host: HTMLElement
|
||||||
|
} | null>(null)
|
||||||
|
const resizeRef = useRef<{
|
||||||
|
handle: ResizeHandle
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
originX: number
|
||||||
|
originY: number
|
||||||
|
lockAspect: boolean
|
||||||
|
} | null>(null)
|
||||||
|
const rotateRef = useRef<{
|
||||||
|
centerX: number
|
||||||
|
centerY: number
|
||||||
|
startAngle: number
|
||||||
|
originRotation: number
|
||||||
|
} | null>(null)
|
||||||
|
const [interacting, setInteracting] = useState(false)
|
||||||
|
const [trackingPointer, setTrackingPointer] = useState(false)
|
||||||
|
const [cropMode, setCropMode] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected) setCropMode(false)
|
||||||
|
}, [selected])
|
||||||
|
|
||||||
|
const onDragPointerDown = useCallback(
|
||||||
|
(event: React.PointerEvent) => {
|
||||||
|
if (!editable || !selected || cropMode) return
|
||||||
|
if ((event.target as HTMLElement).closest(".docs-graphic-handle, .docs-graphic-rotate-handle, .docs-graphic-crop")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
const host = event.currentTarget as HTMLElement
|
||||||
|
pendingDragRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
originX: attrs.x,
|
||||||
|
originY: attrs.y,
|
||||||
|
host,
|
||||||
|
}
|
||||||
|
setTrackingPointer(true)
|
||||||
|
},
|
||||||
|
[attrs.x, attrs.y, cropMode, editable, selected]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onResizeStart = useCallback(
|
||||||
|
(handle: ResizeHandle, event: React.PointerEvent) => {
|
||||||
|
if (!editable || cropMode) return
|
||||||
|
event.preventDefault()
|
||||||
|
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
|
||||||
|
resizeRef.current = {
|
||||||
|
handle,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
width: attrs.width,
|
||||||
|
height: attrs.height,
|
||||||
|
originX: attrs.x,
|
||||||
|
originY: attrs.y,
|
||||||
|
lockAspect: event.shiftKey,
|
||||||
|
}
|
||||||
|
setInteracting(true)
|
||||||
|
},
|
||||||
|
[attrs.height, attrs.width, attrs.x, attrs.y, cropMode, editable]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onRotateStart = useCallback(
|
||||||
|
(event: React.PointerEvent) => {
|
||||||
|
if (!editable || cropMode) return
|
||||||
|
event.preventDefault()
|
||||||
|
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
|
||||||
|
const host = (event.currentTarget as HTMLElement).closest(".docs-graphic") as HTMLElement
|
||||||
|
const rect = host.getBoundingClientRect()
|
||||||
|
const centerX = rect.left + rect.width / 2
|
||||||
|
const centerY = rect.top + rect.height / 2
|
||||||
|
const startAngle = Math.atan2(event.clientY - centerY, event.clientX - centerX)
|
||||||
|
rotateRef.current = {
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
startAngle,
|
||||||
|
originRotation: attrs.rotationDeg,
|
||||||
|
}
|
||||||
|
setInteracting(true)
|
||||||
|
},
|
||||||
|
[attrs.rotationDeg, cropMode, editable]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!interacting && !trackingPointer) return
|
||||||
|
|
||||||
|
const onMove = (event: PointerEvent) => {
|
||||||
|
if (pendingDragRef.current && !dragRef.current) {
|
||||||
|
const pending = pendingDragRef.current
|
||||||
|
if (pending.pointerId !== event.pointerId) return
|
||||||
|
const dx = event.clientX - pending.startX
|
||||||
|
const dy = event.clientY - pending.startY
|
||||||
|
if (Math.hypot(dx, dy) < 4) return
|
||||||
|
|
||||||
|
const prose = pending.host.closest(".ProseMirror") as HTMLElement | null
|
||||||
|
let originX = pending.originX
|
||||||
|
let originY = pending.originY
|
||||||
|
|
||||||
|
const needsAbsolute =
|
||||||
|
attrs.placement !== "absolute" &&
|
||||||
|
attrs.wrap !== "behind" &&
|
||||||
|
attrs.wrap !== "in-front"
|
||||||
|
|
||||||
|
if (needsAbsolute && prose) {
|
||||||
|
const proseRect = prose.getBoundingClientRect()
|
||||||
|
const rect = pending.host.getBoundingClientRect()
|
||||||
|
originX = Math.round(rect.left - proseRect.left)
|
||||||
|
originY = Math.round(rect.top - proseRect.top)
|
||||||
|
updateAttributes({
|
||||||
|
placement: "absolute",
|
||||||
|
x: originX,
|
||||||
|
y: originY,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.host.setPointerCapture(event.pointerId)
|
||||||
|
dragRef.current = {
|
||||||
|
startX: pending.startX,
|
||||||
|
startY: pending.startY,
|
||||||
|
originX,
|
||||||
|
originY,
|
||||||
|
}
|
||||||
|
pendingDragRef.current = null
|
||||||
|
setInteracting(true)
|
||||||
|
}
|
||||||
|
if (dragRef.current) {
|
||||||
|
const { startX, startY, originX, originY } = dragRef.current
|
||||||
|
updateAttributes({
|
||||||
|
x: Math.round(originX + (event.clientX - startX)),
|
||||||
|
y: Math.round(originY + (event.clientY - startY)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (resizeRef.current) {
|
||||||
|
const { handle, startX, startY, width, height, originX, originY, lockAspect } =
|
||||||
|
resizeRef.current
|
||||||
|
const next = resizeWithHandle(
|
||||||
|
handle,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
event.clientX - startX,
|
||||||
|
event.clientY - startY,
|
||||||
|
24,
|
||||||
|
lockAspect || event.shiftKey
|
||||||
|
)
|
||||||
|
const patch: Partial<DocsGraphicAttrs> = {
|
||||||
|
width: next.width,
|
||||||
|
height: next.height,
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
attrs.placement === "absolute" ||
|
||||||
|
attrs.wrap === "behind" ||
|
||||||
|
attrs.wrap === "in-front"
|
||||||
|
) {
|
||||||
|
patch.x = Math.round(originX + next.xOffset)
|
||||||
|
patch.y = Math.round(originY + next.yOffset)
|
||||||
|
}
|
||||||
|
updateAttributes(patch)
|
||||||
|
}
|
||||||
|
if (rotateRef.current) {
|
||||||
|
const { centerX, centerY, startAngle, originRotation } = rotateRef.current
|
||||||
|
const angle = Math.atan2(event.clientY - centerY, event.clientX - centerX)
|
||||||
|
const delta = ((angle - startAngle) * 180) / Math.PI
|
||||||
|
updateAttributes({ rotationDeg: Math.round(originRotation + delta) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
dragRef.current = null
|
||||||
|
pendingDragRef.current = null
|
||||||
|
resizeRef.current = null
|
||||||
|
rotateRef.current = null
|
||||||
|
setInteracting(false)
|
||||||
|
setTrackingPointer(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", onMove)
|
||||||
|
window.addEventListener("pointerup", onUp)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointermove", onMove)
|
||||||
|
window.removeEventListener("pointerup", onUp)
|
||||||
|
}
|
||||||
|
}, [attrs.placement, attrs.wrap, interacting, trackingPointer, updateAttributes])
|
||||||
|
|
||||||
|
const selectNode = useCallback(() => {
|
||||||
|
const pos = getPos()
|
||||||
|
if (typeof pos !== "number") return
|
||||||
|
editor.chain().focus().setNodeSelection(pos).run()
|
||||||
|
}, [editor, getPos])
|
||||||
|
|
||||||
|
const replaceImage = useCallback(() => {
|
||||||
|
replaceInputRef.current?.click()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const graphicBody = (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"docs-graphic",
|
||||||
|
selected && "docs-graphic--selected",
|
||||||
|
interacting && "docs-graphic--interacting",
|
||||||
|
cropMode && "docs-graphic--cropping",
|
||||||
|
layout.behindText && "docs-graphic--behind",
|
||||||
|
layout.inFrontText && "docs-graphic--front"
|
||||||
|
)}
|
||||||
|
style={layout.inner}
|
||||||
|
contentEditable={false}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
selectNode()
|
||||||
|
onDragPointerDown(event)
|
||||||
|
}}
|
||||||
|
onDoubleClick={(event) => {
|
||||||
|
if (!editable || attrs.graphicType !== "image") return
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
setCropMode(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="docs-graphic__content" style={layout.content}>
|
||||||
|
<GraphicContent attrs={attrs} />
|
||||||
|
</div>
|
||||||
|
{selected && editable && cropMode && attrs.graphicType === "image" ? (
|
||||||
|
<DocsGraphicCropOverlay
|
||||||
|
attrs={attrs}
|
||||||
|
frameWidth={attrs.width}
|
||||||
|
frameHeight={attrs.height}
|
||||||
|
onChange={updateAttributes}
|
||||||
|
onDone={() => setCropMode(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{selected && editable && !cropMode ? (
|
||||||
|
<>
|
||||||
|
<span className="docs-graphic-outline" aria-hidden />
|
||||||
|
<RotationHandle onPointerDown={onRotateStart} />
|
||||||
|
{RESIZE_HANDLES.map((handle) => (
|
||||||
|
<ResizeHandleBtn key={handle} handle={handle} onPointerDown={onResizeStart} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<input
|
||||||
|
ref={replaceInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
updateAttributes({ src: reader.result as string })
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
event.target.value = ""
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper
|
||||||
|
as={inline ? "span" : "div"}
|
||||||
|
className={cn("docs-graphic-host", inline && "docs-graphic-host--inline")}
|
||||||
|
style={layout.wrapper}
|
||||||
|
data-graphic-type={attrs.graphicType}
|
||||||
|
data-wrap={attrs.wrap}
|
||||||
|
data-placement={attrs.placement}
|
||||||
|
>
|
||||||
|
{selected && editable ? (
|
||||||
|
<DocsGraphicContextMenu
|
||||||
|
editor={editor}
|
||||||
|
onCrop={() => setCropMode(true)}
|
||||||
|
onReplaceImage={replaceImage}
|
||||||
|
>
|
||||||
|
{graphicBody}
|
||||||
|
</DocsGraphicContextMenu>
|
||||||
|
) : (
|
||||||
|
graphicBody
|
||||||
|
)}
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DocsGraphicNodeView = memo(DocsGraphicNodeViewInner)
|
||||||
257
components/drive/richtext/docs-graphic-options-panel.tsx
Normal file
257
components/drive/richtext/docs-graphic-options-panel.tsx
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { Editor } from "@tiptap/react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
buildGradientCss,
|
||||||
|
DOCS_GRAPHIC_PLACEMENT_LABELS,
|
||||||
|
DOCS_GRAPHIC_WRAP_LABELS,
|
||||||
|
parseGraphicAttrs,
|
||||||
|
type DocsGraphicPlacement,
|
||||||
|
type DocsGraphicWrap,
|
||||||
|
} from "@/lib/drive/docs-graphic-types"
|
||||||
|
import { readGraphicToolbarActive } from "@/components/drive/richtext/docs-graphic-toolbar-menu"
|
||||||
|
|
||||||
|
export function DocsGraphicOptionsPanel({
|
||||||
|
editor,
|
||||||
|
disabled,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
editor: Editor | null
|
||||||
|
disabled?: boolean
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
if (!editor || !readGraphicToolbarActive(editor)) return null
|
||||||
|
|
||||||
|
const name = editor.isActive("docsInlineGraphic") ? "docsInlineGraphic" : "docsGraphic"
|
||||||
|
const attrs = parseGraphicAttrs(editor.getAttributes(name) as Record<string, unknown>)
|
||||||
|
|
||||||
|
const update = (patch: Record<string, unknown>) => {
|
||||||
|
editor.chain().focus().updateDocsGraphic(patch).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
const numField = (
|
||||||
|
label: string,
|
||||||
|
key: "width" | "height" | "x" | "y" | "rotationDeg",
|
||||||
|
step = 1
|
||||||
|
) => (
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="h-8"
|
||||||
|
disabled={disabled}
|
||||||
|
value={attrs[key]}
|
||||||
|
step={step}
|
||||||
|
onChange={(e) => update({ [key]: Number(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={onOpenChange}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="docs-toolbar-btn h-7 shrink-0 px-2 text-xs"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Options
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-72 space-y-3 p-3" align="start">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{attrs.graphicType === "image"
|
||||||
|
? "Options image"
|
||||||
|
: attrs.graphicType === "shape"
|
||||||
|
? "Options forme"
|
||||||
|
: "Options dégradé"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{numField("Largeur (px)", "width")}
|
||||||
|
{numField("Hauteur (px)", "height")}
|
||||||
|
{numField("X", "x")}
|
||||||
|
{numField("Y", "y")}
|
||||||
|
{numField("Rotation (°)", "rotationDeg")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Habillage</Label>
|
||||||
|
<Select
|
||||||
|
disabled={disabled}
|
||||||
|
value={attrs.wrap}
|
||||||
|
onValueChange={(v) => update({ wrap: v as DocsGraphicWrap })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(DOCS_GRAPHIC_WRAP_LABELS) as DocsGraphicWrap[]).map((wrap) => (
|
||||||
|
<SelectItem key={wrap} value={wrap}>
|
||||||
|
{DOCS_GRAPHIC_WRAP_LABELS[wrap]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Placement</Label>
|
||||||
|
<Select
|
||||||
|
disabled={disabled}
|
||||||
|
value={attrs.placement}
|
||||||
|
onValueChange={(v) => update({ placement: v as DocsGraphicPlacement })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(DOCS_GRAPHIC_PLACEMENT_LABELS) as DocsGraphicPlacement[]).map(
|
||||||
|
(placement) => (
|
||||||
|
<SelectItem key={placement} value={placement}>
|
||||||
|
{DOCS_GRAPHIC_PLACEMENT_LABELS[placement]}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{attrs.graphicType === "shape" ? (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Remplissage</Label>
|
||||||
|
<Input
|
||||||
|
type="color"
|
||||||
|
className="h-8 p-1"
|
||||||
|
disabled={disabled}
|
||||||
|
value={attrs.fill}
|
||||||
|
onChange={(e) => update({ fill: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Contour</Label>
|
||||||
|
<Input
|
||||||
|
type="color"
|
||||||
|
className="h-8 p-1"
|
||||||
|
disabled={disabled}
|
||||||
|
value={attrs.stroke}
|
||||||
|
onChange={(e) => update({ stroke: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 grid gap-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Épaisseur contour</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="h-8"
|
||||||
|
disabled={disabled}
|
||||||
|
min={0}
|
||||||
|
value={attrs.strokeWidth}
|
||||||
|
onChange={(e) => update({ strokeWidth: Number(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{attrs.graphicType === "gradient" ? (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Couleur 1</Label>
|
||||||
|
<Input
|
||||||
|
type="color"
|
||||||
|
className="h-8 p-1"
|
||||||
|
disabled={disabled}
|
||||||
|
value={attrs.gradientColor1}
|
||||||
|
onChange={(e) => {
|
||||||
|
const gradientColor1 = e.target.value
|
||||||
|
update({
|
||||||
|
gradientColor1,
|
||||||
|
gradientCss: buildGradientCss(
|
||||||
|
attrs.gradientAngle,
|
||||||
|
gradientColor1,
|
||||||
|
attrs.gradientColor2
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Couleur 2</Label>
|
||||||
|
<Input
|
||||||
|
type="color"
|
||||||
|
className="h-8 p-1"
|
||||||
|
disabled={disabled}
|
||||||
|
value={attrs.gradientColor2}
|
||||||
|
onChange={(e) => {
|
||||||
|
const gradientColor2 = e.target.value
|
||||||
|
update({
|
||||||
|
gradientColor2,
|
||||||
|
gradientCss: buildGradientCss(
|
||||||
|
attrs.gradientAngle,
|
||||||
|
attrs.gradientColor1,
|
||||||
|
gradientColor2
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 grid gap-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Angle (°)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="h-8"
|
||||||
|
disabled={disabled}
|
||||||
|
value={attrs.gradientAngle}
|
||||||
|
onChange={(e) => {
|
||||||
|
const gradientAngle = Number(e.target.value) || 0
|
||||||
|
update({
|
||||||
|
gradientAngle,
|
||||||
|
gradientCss: buildGradientCss(
|
||||||
|
gradientAngle,
|
||||||
|
attrs.gradientColor1,
|
||||||
|
attrs.gradientColor2
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{attrs.graphicType === "image" ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() =>
|
||||||
|
update({ cropX: 0, cropY: 0, cropWidth: 1, cropHeight: 1 })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Réinitialiser le recadrage
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
222
components/drive/richtext/docs-graphic-toolbar-menu.tsx
Normal file
222
components/drive/richtext/docs-graphic-toolbar-menu.tsx
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRef } from "react"
|
||||||
|
import type { Editor } from "@tiptap/react"
|
||||||
|
import { Icon } from "@iconify/react"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { buildInsertGraphicAttrs } from "@/lib/drive/extensions/docs-graphic"
|
||||||
|
import {
|
||||||
|
DOCS_GRAPHIC_PLACEMENT_LABELS,
|
||||||
|
DOCS_GRAPHIC_WRAP_LABELS,
|
||||||
|
type DocsGraphicFloatSide,
|
||||||
|
type DocsGraphicPlacement,
|
||||||
|
type DocsGraphicWrap,
|
||||||
|
parseGraphicAttrs,
|
||||||
|
} from "@/lib/drive/docs-graphic-types"
|
||||||
|
|
||||||
|
function readSelectedGraphicAttrs(editor: Editor) {
|
||||||
|
if (editor.isActive("docsGraphic")) {
|
||||||
|
return parseGraphicAttrs(editor.getAttributes("docsGraphic") as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
if (editor.isActive("docsInlineGraphic")) {
|
||||||
|
return parseGraphicAttrs(editor.getAttributes("docsInlineGraphic") as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocsGraphicInsertMenu({
|
||||||
|
editor,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
editor: Editor | null
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
if (!editor) return null
|
||||||
|
|
||||||
|
const insertImage = (file: File, options?: { wrap?: DocsGraphicWrap; placement?: DocsGraphicPlacement }) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const src = reader.result as string
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertDocsGraphic(
|
||||||
|
buildInsertGraphicAttrs("image", {
|
||||||
|
src,
|
||||||
|
wrap: options?.wrap ?? "square",
|
||||||
|
placement: options?.placement ?? "block",
|
||||||
|
width: 280,
|
||||||
|
height: 180,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="docs-toolbar-btn size-7 shrink-0"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Insérer un élément graphique"
|
||||||
|
>
|
||||||
|
<Icon icon="material-symbols:image-outline" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="min-w-56">
|
||||||
|
<DropdownMenuItem onClick={() => imageInputRef.current?.click()}>
|
||||||
|
Image…
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>Forme</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
{(["rect", "ellipse", "line", "arrow"] as const).map((shapeType) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={shapeType}
|
||||||
|
onClick={() =>
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertDocsGraphic(buildInsertGraphicAttrs("shape", { shapeType }))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{shapeType === "rect"
|
||||||
|
? "Rectangle"
|
||||||
|
: shapeType === "ellipse"
|
||||||
|
? "Ellipse"
|
||||||
|
: shapeType === "line"
|
||||||
|
? "Ligne"
|
||||||
|
: "Flèche"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertDocsGraphic(buildInsertGraphicAttrs("gradient"))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Dégradé
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (file) insertImage(file)
|
||||||
|
event.target.value = ""
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocsGraphicLayoutMenu({
|
||||||
|
editor,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
editor: Editor | null
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
if (!editor) return null
|
||||||
|
const attrs = readSelectedGraphicAttrs(editor)
|
||||||
|
if (!attrs) return null
|
||||||
|
|
||||||
|
const applyWrap = (wrap: DocsGraphicWrap) => {
|
||||||
|
editor.chain().focus().setDocsGraphicWrap(wrap).run()
|
||||||
|
}
|
||||||
|
const applyPlacement = (placement: DocsGraphicPlacement) => {
|
||||||
|
editor.chain().focus().setDocsGraphicPlacement(placement).run()
|
||||||
|
}
|
||||||
|
const applyFloatSide = (floatSide: DocsGraphicFloatSide) => {
|
||||||
|
editor.chain().focus().setDocsGraphicFloatSide(floatSide).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="docs-toolbar-btn docs-toolbar-btn--active size-7 shrink-0"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Disposition de l'élément graphique"
|
||||||
|
>
|
||||||
|
<Icon icon="material-symbols:layers-outline" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="min-w-64">
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>Habillage texte</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
{(Object.keys(DOCS_GRAPHIC_WRAP_LABELS) as DocsGraphicWrap[]).map((wrap) => (
|
||||||
|
<DropdownMenuItem key={wrap} onClick={() => applyWrap(wrap)}>
|
||||||
|
{DOCS_GRAPHIC_WRAP_LABELS[wrap]}
|
||||||
|
{attrs.wrap === wrap ? " ✓" : ""}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>Placement</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
{(Object.keys(DOCS_GRAPHIC_PLACEMENT_LABELS) as DocsGraphicPlacement[]).map(
|
||||||
|
(placement) => (
|
||||||
|
<DropdownMenuItem key={placement} onClick={() => applyPlacement(placement)}>
|
||||||
|
{DOCS_GRAPHIC_PLACEMENT_LABELS[placement]}
|
||||||
|
{attrs.placement === placement ? " ✓" : ""}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>Côté du flottement</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
{(["left", "right", "center"] as const).map((side) => (
|
||||||
|
<DropdownMenuItem key={side} onClick={() => applyFloatSide(side)}>
|
||||||
|
{side === "left" ? "Gauche" : side === "right" ? "Droite" : "Centre"}
|
||||||
|
{attrs.floatSide === side ? " ✓" : ""}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readGraphicToolbarActive(editor: Editor | null): boolean {
|
||||||
|
if (!editor) return false
|
||||||
|
return editor.isActive("docsGraphic") || editor.isActive("docsInlineGraphic")
|
||||||
|
}
|
||||||
208
components/drive/richtext/docs-header-footer-dialogs.tsx
Normal file
208
components/drive/richtext/docs-header-footer-dialogs.tsx
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import type { DocPageNumberSettings, DocPageSetup } from "@/lib/drive/doc-page-setup"
|
||||||
|
import { cmToMm, mmToCm } from "@/lib/drive/doc-page-setup"
|
||||||
|
|
||||||
|
export function DocsHeaderFooterFormatDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
pageSetup,
|
||||||
|
onApply,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
pageSetup: DocPageSetup | null
|
||||||
|
onApply: (patch: Partial<DocPageSetup>) => void
|
||||||
|
}) {
|
||||||
|
const headerCm = mmToCm(pageSetup?.headerMarginMm ?? pageSetup?.marginsMm.top ?? 20)
|
||||||
|
const footerCm = mmToCm(pageSetup?.footerMarginMm ?? pageSetup?.marginsMm.bottom ?? 20)
|
||||||
|
const [headerMarginCm, setHeaderMarginCm] = useState(headerCm)
|
||||||
|
const [footerMarginCm, setFooterMarginCm] = useState(footerCm)
|
||||||
|
const [differentFirst, setDifferentFirst] = useState(
|
||||||
|
pageSetup?.headerFooterDifferentFirstPage ?? false
|
||||||
|
)
|
||||||
|
const [differentOddEven, setDifferentOddEven] = useState(
|
||||||
|
pageSetup?.headerFooterDifferentOddEven ?? false
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>En-têtes et pieds de page</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium">Marges</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
En-tête (cm depuis le haut)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.1}
|
||||||
|
value={headerMarginCm}
|
||||||
|
onChange={(e) => setHeaderMarginCm(Number(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
Pied de page (cm depuis le bas)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.1}
|
||||||
|
value={footerMarginCm}
|
||||||
|
onChange={(e) => setFooterMarginCm(Number(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium">Mise en page</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={differentFirst}
|
||||||
|
onCheckedChange={(v) => setDifferentFirst(v === true)}
|
||||||
|
/>
|
||||||
|
Première page différente
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={differentOddEven}
|
||||||
|
onCheckedChange={(v) => setDifferentOddEven(v === true)}
|
||||||
|
/>
|
||||||
|
Différente pour les pages paires et impaires
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onApply({
|
||||||
|
headerMarginMm: cmToMm(headerMarginCm),
|
||||||
|
footerMarginMm: cmToMm(footerMarginCm),
|
||||||
|
headerFooterDifferentFirstPage: differentFirst,
|
||||||
|
headerFooterDifferentOddEven: differentOddEven,
|
||||||
|
})
|
||||||
|
onOpenChange(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Appliquer
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocsPageNumbersDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
settings,
|
||||||
|
onApply,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
settings: DocPageNumberSettings | null | undefined
|
||||||
|
onApply: (settings: DocPageNumberSettings) => void
|
||||||
|
}) {
|
||||||
|
const [placement, setPlacement] = useState<"header" | "footer">(
|
||||||
|
settings?.placement ?? "header"
|
||||||
|
)
|
||||||
|
const [startAt, setStartAt] = useState(settings?.startAt ?? 1)
|
||||||
|
const [showOnFirstPage, setShowOnFirstPage] = useState(settings?.showOnFirstPage ?? true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Numéros de page</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium">Position</p>
|
||||||
|
<div className="flex gap-4 text-sm">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="page-num-placement"
|
||||||
|
checked={placement === "header"}
|
||||||
|
onChange={() => setPlacement("header")}
|
||||||
|
/>
|
||||||
|
En-tête
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="page-num-placement"
|
||||||
|
checked={placement === "footer"}
|
||||||
|
onChange={() => setPlacement("footer")}
|
||||||
|
/>
|
||||||
|
Pied de page
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={showOnFirstPage}
|
||||||
|
onCheckedChange={(v) => setShowOnFirstPage(v === true)}
|
||||||
|
/>
|
||||||
|
Afficher sur la première page
|
||||||
|
</label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Commencer à</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={startAt}
|
||||||
|
onChange={(e) => setStartAt(Number(e.target.value) || 0)}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onApply({
|
||||||
|
enabled: true,
|
||||||
|
placement,
|
||||||
|
align: "right",
|
||||||
|
startAt,
|
||||||
|
showOnFirstPage,
|
||||||
|
})
|
||||||
|
onOpenChange(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Appliquer
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
481
components/drive/richtext/docs-header-footer-region.tsx
Normal file
481
components/drive/richtext/docs-header-footer-region.tsx
Normal file
@ -0,0 +1,481 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import { ChevronDown } from "lucide-react"
|
||||||
|
import type { Editor } from "@tiptap/react"
|
||||||
|
import type { DocPageLayout, DocPageSetup } from "@/lib/drive/doc-page-setup"
|
||||||
|
import { pxToMm } from "@/lib/drive/doc-page-setup"
|
||||||
|
import {
|
||||||
|
DOCS_HF_CHROME_BAR_PX,
|
||||||
|
defaultFooterZoneHeightPx,
|
||||||
|
defaultHeaderZoneHeightPx,
|
||||||
|
pageFooterGeometry,
|
||||||
|
pageHeaderGeometry,
|
||||||
|
resolveRegionForPage,
|
||||||
|
toggleDifferentFirstPage,
|
||||||
|
type DocsHeaderFooterRegion,
|
||||||
|
} from "@/lib/drive/docs-header-footer-layout"
|
||||||
|
import { DocsRegionEditor } from "@/components/drive/richtext/docs-region-editor"
|
||||||
|
import {
|
||||||
|
DocsHeaderFooterFormatDialog,
|
||||||
|
DocsPageNumbersDialog,
|
||||||
|
} from "@/components/drive/richtext/docs-header-footer-dialogs"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export function extractRegionPlainText(content: Record<string, unknown> | undefined): string {
|
||||||
|
if (!content) return ""
|
||||||
|
const parts: string[] = []
|
||||||
|
const walk = (node: unknown) => {
|
||||||
|
if (!node || typeof node !== "object") return
|
||||||
|
const record = node as Record<string, unknown>
|
||||||
|
if (typeof record.text === "string") parts.push(record.text)
|
||||||
|
if (Array.isArray(record.content)) record.content.forEach(walk)
|
||||||
|
}
|
||||||
|
walk(content)
|
||||||
|
return parts.join(" ").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { DocsHeaderFooterRegion }
|
||||||
|
|
||||||
|
export type DocsHeaderFooterEditTarget = {
|
||||||
|
region: DocsHeaderFooterRegion
|
||||||
|
pageIndex: number
|
||||||
|
} | null
|
||||||
|
|
||||||
|
function DocsHeaderFooterChrome({
|
||||||
|
label,
|
||||||
|
pageWidth,
|
||||||
|
barTop,
|
||||||
|
placement,
|
||||||
|
showFirstPageCheckbox,
|
||||||
|
differentFirstPage,
|
||||||
|
onDifferentFirstPageChange,
|
||||||
|
onFormatOpen,
|
||||||
|
onPageNumOpen,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
pageWidth: number
|
||||||
|
barTop: number
|
||||||
|
placement: DocsHeaderFooterRegion
|
||||||
|
showFirstPageCheckbox: boolean
|
||||||
|
differentFirstPage: boolean
|
||||||
|
onDifferentFirstPageChange: (checked: boolean) => void
|
||||||
|
onFormatOpen: () => void
|
||||||
|
onPageNumOpen: () => void
|
||||||
|
onRemove: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"docs-hf-chrome pointer-events-none absolute",
|
||||||
|
placement === "footer" && "docs-hf-chrome--footer"
|
||||||
|
)}
|
||||||
|
style={{ top: barTop, left: 0, width: pageWidth, height: DOCS_HF_CHROME_BAR_PX }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"docs-hf-chrome__bar pointer-events-auto flex h-full items-center justify-between bg-[#f1f3f4] px-4",
|
||||||
|
placement === "header" ? "border-t border-[#dadce0]" : "border-b border-[#dadce0]"
|
||||||
|
)}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<span className="docs-hf-chrome__label">{label}</span>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{showFirstPageCheckbox ? (
|
||||||
|
<label className="docs-hf-chrome__checkbox-label flex cursor-pointer items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={differentFirstPage}
|
||||||
|
onCheckedChange={(v) => onDifferentFirstPageChange(v === true)}
|
||||||
|
className="docs-hf-chrome__checkbox"
|
||||||
|
/>
|
||||||
|
Première page différente
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="docs-hf-chrome__options h-7 gap-1 px-1.5 hover:bg-transparent dark:hover:bg-transparent"
|
||||||
|
>
|
||||||
|
Options
|
||||||
|
<ChevronDown className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={onFormatOpen}>
|
||||||
|
{label === "En-tête"
|
||||||
|
? "Format de l'en-tête"
|
||||||
|
: "Format du pied de page"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onPageNumOpen}>
|
||||||
|
Numéros de page
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onRemove}>
|
||||||
|
{label === "En-tête"
|
||||||
|
? "Supprimer l'en-tête"
|
||||||
|
: "Supprimer le pied de page"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocsHeaderFooterBand({
|
||||||
|
region,
|
||||||
|
pageLayout,
|
||||||
|
pageIndex,
|
||||||
|
pageTop,
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
margins,
|
||||||
|
editable,
|
||||||
|
editingTarget,
|
||||||
|
onStartEdit,
|
||||||
|
onStopEdit,
|
||||||
|
onContentChange,
|
||||||
|
onPageSetupChange,
|
||||||
|
onRegionEditorReady,
|
||||||
|
onRegionHeightMeasure,
|
||||||
|
}: {
|
||||||
|
region: DocsHeaderFooterRegion
|
||||||
|
pageLayout: DocPageLayout
|
||||||
|
pageIndex: number
|
||||||
|
pageTop: number
|
||||||
|
pageWidth: number
|
||||||
|
pageHeight: number
|
||||||
|
margins: { top: number; right: number; bottom: number; left: number }
|
||||||
|
editable: boolean
|
||||||
|
editingTarget: DocsHeaderFooterEditTarget
|
||||||
|
onStartEdit: (region: DocsHeaderFooterRegion, pageIndex: number) => void
|
||||||
|
onStopEdit: () => void
|
||||||
|
onContentChange: (
|
||||||
|
region: DocsHeaderFooterRegion,
|
||||||
|
content: Record<string, unknown>,
|
||||||
|
meta: { pageIndex: number; contentHeightPx: number },
|
||||||
|
options?: { immediate?: boolean }
|
||||||
|
) => void
|
||||||
|
onPageSetupChange: (patch: Partial<DocPageSetup>) => void
|
||||||
|
onRegionEditorReady?: (editor: Editor | null) => void
|
||||||
|
onRegionHeightMeasure?: (payload: {
|
||||||
|
region: DocsHeaderFooterRegion
|
||||||
|
pageIndex: number
|
||||||
|
heightPx: number
|
||||||
|
}) => void
|
||||||
|
}) {
|
||||||
|
const isHeader = region === "header"
|
||||||
|
const isEditing =
|
||||||
|
editingTarget?.region === region && editingTarget.pageIndex === pageIndex
|
||||||
|
const canEdit = editable
|
||||||
|
|
||||||
|
const regionData = resolveRegionForPage(pageLayout, region, pageIndex)
|
||||||
|
const differentFirstPage = pageLayout.headerFooterDifferentFirstPage ?? false
|
||||||
|
|
||||||
|
const headerGeom = pageHeaderGeometry(pageLayout, pageTop, pageIndex)
|
||||||
|
const footerGeom = pageFooterGeometry(pageLayout, pageTop, pageHeight, pageIndex)
|
||||||
|
|
||||||
|
const bandLeft = margins.left
|
||||||
|
const bandWidth = pageWidth - margins.left - margins.right
|
||||||
|
|
||||||
|
const zoneHeight = isHeader ? headerGeom.zoneHeight : footerGeom.zoneHeight
|
||||||
|
const minZoneHeight = isHeader
|
||||||
|
? defaultHeaderZoneHeightPx(pageLayout)
|
||||||
|
: defaultFooterZoneHeightPx(pageLayout)
|
||||||
|
|
||||||
|
const [liveHeight, setLiveHeight] = useState(zoneHeight)
|
||||||
|
const liveHeightRef = useRef(zoneHeight)
|
||||||
|
liveHeightRef.current = liveHeight
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing) return
|
||||||
|
setLiveHeight((height) => Math.min(height, zoneHeight))
|
||||||
|
}, [isEditing, zoneHeight])
|
||||||
|
|
||||||
|
/** View: content-sized. Edit: at least default header/footer margin band. */
|
||||||
|
const contentHeight = isEditing
|
||||||
|
? Math.max(minZoneHeight, liveHeight)
|
||||||
|
: Math.max(20, liveHeight)
|
||||||
|
|
||||||
|
const zoneTop = isHeader
|
||||||
|
? headerGeom.zoneTop
|
||||||
|
: footerGeom.zoneBottom - contentHeight
|
||||||
|
|
||||||
|
const headerChromeTop = headerGeom.zoneTop + contentHeight
|
||||||
|
const footerChromeTop =
|
||||||
|
footerGeom.zoneBottom - contentHeight - DOCS_HF_CHROME_BAR_PX
|
||||||
|
const chromeBarTop = isHeader ? headerChromeTop : footerChromeTop
|
||||||
|
|
||||||
|
const [formatOpen, setFormatOpen] = useState(false)
|
||||||
|
const [pageNumOpen, setPageNumOpen] = useState(false)
|
||||||
|
const contentPersistTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const latestContentRef = useRef<Record<string, unknown> | null>(null)
|
||||||
|
|
||||||
|
const persistRegionContent = useCallback(
|
||||||
|
(content: Record<string, unknown>, immediate = false) => {
|
||||||
|
latestContentRef.current = content
|
||||||
|
onContentChange(
|
||||||
|
region,
|
||||||
|
content,
|
||||||
|
{
|
||||||
|
pageIndex,
|
||||||
|
contentHeightPx: Math.max(minZoneHeight, liveHeightRef.current),
|
||||||
|
},
|
||||||
|
immediate ? { immediate: true } : undefined
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[minZoneHeight, onContentChange, pageIndex, region]
|
||||||
|
)
|
||||||
|
|
||||||
|
const scheduleRegionContentPersist = useCallback(
|
||||||
|
(content: Record<string, unknown>) => {
|
||||||
|
latestContentRef.current = content
|
||||||
|
if (contentPersistTimer.current) clearTimeout(contentPersistTimer.current)
|
||||||
|
contentPersistTimer.current = setTimeout(() => {
|
||||||
|
persistRegionContent(content)
|
||||||
|
}, 800)
|
||||||
|
},
|
||||||
|
[persistRegionContent]
|
||||||
|
)
|
||||||
|
|
||||||
|
const pageNumber =
|
||||||
|
pageLayout.pageNumbers?.enabled &&
|
||||||
|
pageLayout.pageNumbers.placement === region &&
|
||||||
|
(pageLayout.pageNumbers.showOnFirstPage || pageIndex > 0)
|
||||||
|
? (pageLayout.pageNumbers.startAt ?? 1) + pageIndex
|
||||||
|
: null
|
||||||
|
|
||||||
|
const handleRemove = useCallback(() => {
|
||||||
|
onContentChange(
|
||||||
|
region,
|
||||||
|
{ type: "doc", content: [{ type: "paragraph" }] },
|
||||||
|
{ pageIndex, contentHeightPx: minZoneHeight },
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
onPageSetupChange(isHeader ? { header: null, headerFirstPage: null } : { footer: null, footerFirstPage: null })
|
||||||
|
onStopEdit()
|
||||||
|
}, [isHeader, minZoneHeight, onContentChange, onPageSetupChange, onStopEdit, pageIndex, region])
|
||||||
|
|
||||||
|
const setupPatch: DocPageSetup = {
|
||||||
|
widthMm: pageLayout.format.widthMm,
|
||||||
|
heightMm: pageLayout.format.heightMm,
|
||||||
|
marginsMm: {
|
||||||
|
top: pxToMm(pageLayout.marginsPx.top),
|
||||||
|
right: pxToMm(pageLayout.marginsPx.right),
|
||||||
|
bottom: pxToMm(pageLayout.marginsPx.bottom),
|
||||||
|
left: pxToMm(pageLayout.marginsPx.left),
|
||||||
|
},
|
||||||
|
headerMarginMm: pageLayout.headerMarginMm,
|
||||||
|
footerMarginMm: pageLayout.footerMarginMm,
|
||||||
|
headerFooterDifferentFirstPage: differentFirstPage,
|
||||||
|
headerFooterDifferentOddEven: pageLayout.headerFooterDifferentOddEven,
|
||||||
|
pageNumbers: pageLayout.pageNumbers,
|
||||||
|
header: pageLayout.header,
|
||||||
|
footer: pageLayout.footer,
|
||||||
|
headerFirstPage: pageLayout.headerFirstPage,
|
||||||
|
footerFirstPage: pageLayout.footerFirstPage,
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasContent =
|
||||||
|
regionData?.content && extractRegionPlainText(regionData.content).length > 0
|
||||||
|
|
||||||
|
const regionLabel = isHeader ? "En-tête" : "Pied de page"
|
||||||
|
|
||||||
|
const handleDoubleClick = (event: React.MouseEvent) => {
|
||||||
|
if (!canEdit) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
onStartEdit(region, pageIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistHeight = useCallback(
|
||||||
|
(heightPx: number) => {
|
||||||
|
const measured = Math.max(20, heightPx)
|
||||||
|
setLiveHeight(measured)
|
||||||
|
onRegionHeightMeasure?.({ region, pageIndex, heightPx: measured })
|
||||||
|
},
|
||||||
|
[onRegionHeightMeasure, pageIndex, region]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (contentPersistTimer.current) clearTimeout(contentPersistTimer.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const wasEditingRef = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (wasEditingRef.current && !isEditing) {
|
||||||
|
if (contentPersistTimer.current) {
|
||||||
|
clearTimeout(contentPersistTimer.current)
|
||||||
|
contentPersistTimer.current = null
|
||||||
|
}
|
||||||
|
persistRegionContent(
|
||||||
|
(latestContentRef.current ??
|
||||||
|
regionData?.content ??
|
||||||
|
({ type: "doc", content: [{ type: "paragraph" }] } as Record<string, unknown>)),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
wasEditingRef.current = isEditing
|
||||||
|
}, [isEditing, persistRegionContent, regionData?.content])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="docs-hf-editing-backdrop pointer-events-none absolute"
|
||||||
|
style={{
|
||||||
|
top: zoneTop,
|
||||||
|
left: 0,
|
||||||
|
width: pageWidth,
|
||||||
|
height: contentHeight,
|
||||||
|
backgroundColor: pageLayout.pageColor,
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isHeader ? (
|
||||||
|
<div
|
||||||
|
className="docs-hf-separator pointer-events-none absolute border-t border-[#dadce0]"
|
||||||
|
style={{ top: headerGeom.zoneTop, left: 0, width: pageWidth }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="docs-hf-separator pointer-events-none absolute border-b border-[#dadce0]"
|
||||||
|
style={{ top: footerGeom.zoneBottom, left: 0, width: pageWidth }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DocsHeaderFooterChrome
|
||||||
|
label={regionLabel}
|
||||||
|
pageWidth={pageWidth}
|
||||||
|
barTop={chromeBarTop}
|
||||||
|
placement={region}
|
||||||
|
showFirstPageCheckbox={pageIndex === 0}
|
||||||
|
differentFirstPage={differentFirstPage}
|
||||||
|
onDifferentFirstPageChange={(checked) => {
|
||||||
|
onPageSetupChange(toggleDifferentFirstPage(setupPatch, checked))
|
||||||
|
}}
|
||||||
|
onFormatOpen={() => setFormatOpen(true)}
|
||||||
|
onPageNumOpen={() => setPageNumOpen(true)}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isEditing && !hasContent && canEdit ? (
|
||||||
|
<div
|
||||||
|
className="docs-hf-hit-area absolute cursor-text"
|
||||||
|
style={{
|
||||||
|
top: isHeader ? headerGeom.zoneTop : footerGeom.zoneBottom - minZoneHeight,
|
||||||
|
left: bandLeft,
|
||||||
|
width: bandWidth,
|
||||||
|
height: minZoneHeight,
|
||||||
|
}}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isEditing || hasContent ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"docs-hf-band absolute",
|
||||||
|
isHeader ? "docs-hf-band--header" : "docs-hf-band--footer",
|
||||||
|
isEditing && "docs-hf-band--editing"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: zoneTop,
|
||||||
|
left: bandLeft,
|
||||||
|
width: bandWidth,
|
||||||
|
height: contentHeight,
|
||||||
|
...(isEditing
|
||||||
|
? {
|
||||||
|
backgroundColor: pageLayout.pageColor,
|
||||||
|
"--docs-hf-page-color": pageLayout.pageColor,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
>
|
||||||
|
<DocsRegionEditor
|
||||||
|
content={regionData?.content}
|
||||||
|
editable={isEditing}
|
||||||
|
autoFocus={isEditing}
|
||||||
|
placeholder={regionLabel}
|
||||||
|
minHeightPx={isEditing ? minZoneHeight : undefined}
|
||||||
|
onUpdate={isEditing ? scheduleRegionContentPersist : undefined}
|
||||||
|
onBlur={
|
||||||
|
isEditing
|
||||||
|
? () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const active = document.activeElement
|
||||||
|
if (
|
||||||
|
active?.closest(".docs-hf-chrome") ||
|
||||||
|
active?.closest('[role="dialog"]') ||
|
||||||
|
active?.closest("[data-radix-popper-content-wrapper]")
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onStopEdit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onEditorReady={isEditing ? onRegionEditorReady : undefined}
|
||||||
|
onContentHeightChange={persistHeight}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{pageNumber != null && !isEditing ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute text-[11px] text-[#5f6368]",
|
||||||
|
pageLayout.pageNumbers?.align === "center"
|
||||||
|
? "left-1/2 -translate-x-1/2"
|
||||||
|
: pageLayout.pageNumbers?.align === "left"
|
||||||
|
? "left-0"
|
||||||
|
: "right-0",
|
||||||
|
isHeader ? "top-0" : "bottom-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{pageNumber}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<DocsHeaderFooterFormatDialog
|
||||||
|
open={formatOpen}
|
||||||
|
onOpenChange={setFormatOpen}
|
||||||
|
pageSetup={setupPatch}
|
||||||
|
onApply={onPageSetupChange}
|
||||||
|
/>
|
||||||
|
<DocsPageNumbersDialog
|
||||||
|
open={pageNumOpen}
|
||||||
|
onOpenChange={setPageNumOpen}
|
||||||
|
settings={pageLayout.pageNumbers}
|
||||||
|
onApply={(pageNumbers) => onPageSetupChange({ pageNumbers })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
components/drive/richtext/docs-horizontal-ruler.tsx
Normal file
139
components/drive/richtext/docs-horizontal-ruler.tsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { memo, useRef } from "react"
|
||||||
|
import type { DocPageLayout } from "@/lib/drive/doc-page-setup"
|
||||||
|
import { DOCS_HORIZONTAL_RULER_HEIGHT_PX } from "@/lib/drive/docs-page-layout-constants"
|
||||||
|
import { docsPageLengthToScreen } from "@/lib/drive/docs-ruler-scale"
|
||||||
|
import type { DocsParagraphIndents } from "@/lib/drive/use-docs-ruler-sync"
|
||||||
|
import { buildHorizontalRulerTicks } from "@/lib/drive/docs-ruler-math"
|
||||||
|
import type { DocsRulerMarginSide } from "@/lib/drive/docs-ruler-margin-math"
|
||||||
|
import {
|
||||||
|
DocsRulerDraggableHandle,
|
||||||
|
DocsRulerFirstLineMarker,
|
||||||
|
DocsRulerTriangleMarker,
|
||||||
|
useRulerPointerDrag,
|
||||||
|
} from "@/components/drive/richtext/docs-ruler-markers"
|
||||||
|
|
||||||
|
function DocsHorizontalRulerInner({
|
||||||
|
pageLayout,
|
||||||
|
scale,
|
||||||
|
indents,
|
||||||
|
editable,
|
||||||
|
onMarginDragStart,
|
||||||
|
onMarginDrag,
|
||||||
|
onMarginDragEnd,
|
||||||
|
}: {
|
||||||
|
pageLayout: DocPageLayout
|
||||||
|
scale: number
|
||||||
|
indents: DocsParagraphIndents
|
||||||
|
editable?: boolean
|
||||||
|
onMarginDragStart?: (side: DocsRulerMarginSide) => void
|
||||||
|
onMarginDrag?: (side: DocsRulerMarginSide, pagePx: number, clientX: number, clientY: number) => void
|
||||||
|
onMarginDragEnd?: () => void
|
||||||
|
}) {
|
||||||
|
const rulerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const pageWidth = pageLayout.widthPx
|
||||||
|
const margins = pageLayout.marginsPx
|
||||||
|
const scaledWidth = docsPageLengthToScreen(pageWidth, scale)
|
||||||
|
const ticks = buildHorizontalRulerTicks(pageWidth, pageLayout.format.id)
|
||||||
|
const s = (px: number) => docsPageLengthToScreen(px, scale)
|
||||||
|
|
||||||
|
const leftDrag = useRulerPointerDrag({
|
||||||
|
rulerRef,
|
||||||
|
axis: "horizontal",
|
||||||
|
disabled: !editable,
|
||||||
|
onDrag: (pagePx, clientX, clientY) => onMarginDrag?.("left", pagePx, clientX, clientY),
|
||||||
|
onDragEnd: () => onMarginDragEnd?.(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const rightDrag = useRulerPointerDrag({
|
||||||
|
rulerRef,
|
||||||
|
axis: "horizontal",
|
||||||
|
disabled: !editable,
|
||||||
|
onDrag: (pagePx, clientX, clientY) => onMarginDrag?.("right", pagePx, clientX, clientY),
|
||||||
|
onDragEnd: () => onMarginDragEnd?.(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapMarginDown =
|
||||||
|
(side: DocsRulerMarginSide, handler: (e: React.PointerEvent<HTMLDivElement>) => void) =>
|
||||||
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
onMarginDragStart?.(side)
|
||||||
|
handler(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rulerRef}
|
||||||
|
data-docs-ruler="horizontal"
|
||||||
|
data-docs-ruler-scale={scale}
|
||||||
|
className="docs-horizontal-ruler relative overflow-visible bg-transparent"
|
||||||
|
style={{
|
||||||
|
width: scaledWidth,
|
||||||
|
height: DOCS_HORIZONTAL_RULER_HEIGHT_PX,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute top-0 h-full bg-transparent"
|
||||||
|
style={{ left: 0, width: s(margins.left) }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute top-0 h-full bg-transparent"
|
||||||
|
style={{ left: s(pageWidth - margins.right), width: s(margins.right) }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ticks.map((tick, index) => (
|
||||||
|
<div
|
||||||
|
key={`${tick.pos}-${index}`}
|
||||||
|
className="pointer-events-none absolute bottom-0 w-px bg-[#80868b] dark:bg-muted-foreground/70"
|
||||||
|
style={{
|
||||||
|
left: s(tick.pos),
|
||||||
|
height: tick.major ? 10 : 5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{ticks
|
||||||
|
.filter((tick) => tick.major && tick.label != null)
|
||||||
|
.map((tick) => (
|
||||||
|
<span
|
||||||
|
key={`label-${tick.pos}`}
|
||||||
|
className="pointer-events-none absolute top-[2px] -translate-x-1/2 text-[9px] leading-none text-[#5f6368] dark:text-muted-foreground"
|
||||||
|
style={{ left: s(tick.pos) }}
|
||||||
|
>
|
||||||
|
{tick.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<DocsRulerDraggableHandle
|
||||||
|
style={{ left: s(margins.left) }}
|
||||||
|
axis="horizontal"
|
||||||
|
disabled={!editable}
|
||||||
|
ariaLabel="Marge gauche"
|
||||||
|
onPointerDown={wrapMarginDown("left", leftDrag.onPointerDown)}
|
||||||
|
>
|
||||||
|
<DocsRulerTriangleMarker left={0} className="relative" />
|
||||||
|
</DocsRulerDraggableHandle>
|
||||||
|
|
||||||
|
<DocsRulerDraggableHandle
|
||||||
|
style={{ left: s(pageWidth - margins.right) }}
|
||||||
|
axis="horizontal"
|
||||||
|
disabled={!editable}
|
||||||
|
ariaLabel="Marge droite"
|
||||||
|
onPointerDown={wrapMarginDown("right", rightDrag.onPointerDown)}
|
||||||
|
>
|
||||||
|
<DocsRulerTriangleMarker left={0} className="relative" />
|
||||||
|
</DocsRulerDraggableHandle>
|
||||||
|
|
||||||
|
<DocsRulerTriangleMarker
|
||||||
|
left={s(indents.leftPx)}
|
||||||
|
className="pointer-events-none absolute bottom-0 -translate-x-1/2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{Math.abs(indents.firstLinePx - indents.leftPx) > 1 ? (
|
||||||
|
<DocsRulerFirstLineMarker left={s(indents.firstLinePx)} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DocsHorizontalRuler = memo(DocsHorizontalRulerInner)
|
||||||
@ -2,31 +2,23 @@
|
|||||||
|
|
||||||
import { DocsEditMenu, type DocsEditMenuActions, type DocsEditMenuState } from "@/components/drive/richtext/docs-edit-menu"
|
import { DocsEditMenu, type DocsEditMenuActions, type DocsEditMenuState } from "@/components/drive/richtext/docs-edit-menu"
|
||||||
import { DocsFileMenu, type DocsFileMenuActions } from "@/components/drive/richtext/docs-file-menu"
|
import { DocsFileMenu, type DocsFileMenuActions } from "@/components/drive/richtext/docs-file-menu"
|
||||||
|
import { DocsViewMenu, type DocsViewMenuActions, type DocsViewMenuState } from "@/components/drive/richtext/docs-view-menu"
|
||||||
import { DOCS_MENUBAR_CONTENT_PROPS } from "@/components/drive/richtext/docs-menubar-props"
|
import { DOCS_MENUBAR_CONTENT_PROPS } from "@/components/drive/richtext/docs-menubar-props"
|
||||||
import {
|
import {
|
||||||
Menubar,
|
Menubar,
|
||||||
MenubarContent,
|
MenubarContent,
|
||||||
MenubarItem,
|
MenubarItem,
|
||||||
MenubarMenu,
|
MenubarMenu,
|
||||||
MenubarSeparator,
|
|
||||||
MenubarTrigger,
|
MenubarTrigger,
|
||||||
} from "@/components/ui/menubar"
|
} from "@/components/ui/menubar"
|
||||||
import { PAGE_FORMATS, type PageFormatId } from "@/lib/drive/page-formats"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const OTHER_MENU_LABELS = [
|
const OTHER_MENU_LABELS = ["Insertion", "Format", "Outils", "Aide"] as const
|
||||||
"Affichage",
|
|
||||||
"Insertion",
|
|
||||||
"Format",
|
|
||||||
"Outils",
|
|
||||||
"Aide",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export function DocsMenubar({
|
export function DocsMenubar({
|
||||||
pageFormatId,
|
viewMenuActions,
|
||||||
onPageFormatChange,
|
viewMenuState,
|
||||||
zoom,
|
viewMenuDisabled,
|
||||||
onZoomChange,
|
|
||||||
fileMenuActions,
|
fileMenuActions,
|
||||||
fileMenuDisabled,
|
fileMenuDisabled,
|
||||||
editMenuActions,
|
editMenuActions,
|
||||||
@ -34,10 +26,9 @@ export function DocsMenubar({
|
|||||||
editMenuDisabled,
|
editMenuDisabled,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
pageFormatId: PageFormatId
|
viewMenuActions?: DocsViewMenuActions
|
||||||
onPageFormatChange: (id: PageFormatId) => void
|
viewMenuState?: DocsViewMenuState
|
||||||
zoom: number
|
viewMenuDisabled?: boolean
|
||||||
onZoomChange: (zoom: number) => void
|
|
||||||
fileMenuActions?: DocsFileMenuActions
|
fileMenuActions?: DocsFileMenuActions
|
||||||
fileMenuDisabled?: boolean
|
fileMenuDisabled?: boolean
|
||||||
editMenuActions?: DocsEditMenuActions
|
editMenuActions?: DocsEditMenuActions
|
||||||
@ -82,54 +73,24 @@ export function DocsMenubar({
|
|||||||
</MenubarMenu>
|
</MenubarMenu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{OTHER_MENU_LABELS.map((label) => {
|
{viewMenuActions && viewMenuState ? (
|
||||||
if (label === "Affichage") {
|
<DocsViewMenu
|
||||||
return (
|
actions={viewMenuActions}
|
||||||
<MenubarMenu key={label}>
|
state={viewMenuState}
|
||||||
<MenubarTrigger className="docs-menu-trigger">{label}</MenubarTrigger>
|
disabled={viewMenuDisabled}
|
||||||
<MenubarContent
|
/>
|
||||||
{...DOCS_MENUBAR_CONTENT_PROPS}
|
) : (
|
||||||
className="docs-menu-content min-w-52 overflow-visible"
|
<MenubarMenu>
|
||||||
data-docs-menu-surface
|
<MenubarTrigger className="docs-menu-trigger">Affichage</MenubarTrigger>
|
||||||
>
|
<MenubarContent {...DOCS_MENUBAR_CONTENT_PROPS} data-docs-menu-surface>
|
||||||
<MenubarItem disabled className="text-xs text-muted-foreground">
|
<MenubarItem disabled className="text-muted-foreground">
|
||||||
Mode (bientôt)
|
Bientôt disponible
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
<MenubarSeparator />
|
</MenubarContent>
|
||||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
</MenubarMenu>
|
||||||
Taille de page
|
)}
|
||||||
</div>
|
|
||||||
{PAGE_FORMATS.map((format) => (
|
|
||||||
<MenubarItem
|
|
||||||
key={format.id}
|
|
||||||
onClick={() => onPageFormatChange(format.id)}
|
|
||||||
className={cn(pageFormatId === format.id && "bg-accent")}
|
|
||||||
>
|
|
||||||
{format.label}
|
|
||||||
<span className="ml-auto text-xs text-muted-foreground">
|
|
||||||
{format.widthMm} × {format.heightMm} mm
|
|
||||||
</span>
|
|
||||||
</MenubarItem>
|
|
||||||
))}
|
|
||||||
<MenubarSeparator />
|
|
||||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
|
||||||
Zoom
|
|
||||||
</div>
|
|
||||||
{[50, 75, 100, 125, 150, 200].map((value) => (
|
|
||||||
<MenubarItem
|
|
||||||
key={value}
|
|
||||||
onClick={() => onZoomChange(value)}
|
|
||||||
className={cn(zoom === value && "bg-accent")}
|
|
||||||
>
|
|
||||||
{value}%
|
|
||||||
</MenubarItem>
|
|
||||||
))}
|
|
||||||
</MenubarContent>
|
|
||||||
</MenubarMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
{OTHER_MENU_LABELS.map((label) => (
|
||||||
<MenubarMenu key={label}>
|
<MenubarMenu key={label}>
|
||||||
<MenubarTrigger className="docs-menu-trigger">{label}</MenubarTrigger>
|
<MenubarTrigger className="docs-menu-trigger">{label}</MenubarTrigger>
|
||||||
<MenubarContent
|
<MenubarContent
|
||||||
@ -142,8 +103,7 @@ export function DocsMenubar({
|
|||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
</MenubarContent>
|
</MenubarContent>
|
||||||
</MenubarMenu>
|
</MenubarMenu>
|
||||||
)
|
))}
|
||||||
})}
|
|
||||||
</Menubar>
|
</Menubar>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +1,62 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { memo, useEffect, useRef, useState } from "react"
|
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"
|
||||||
import { EditorContent, type Editor } from "@tiptap/react"
|
import { EditorContent, type Editor } from "@tiptap/react"
|
||||||
import type { DocPageLayout } from "@/lib/drive/doc-page-setup"
|
import type { DocPageLayout, DocPageSetup } from "@/lib/drive/doc-page-setup"
|
||||||
|
import {
|
||||||
|
DocsHeaderFooterBand,
|
||||||
|
type DocsHeaderFooterEditTarget,
|
||||||
|
type DocsHeaderFooterRegion,
|
||||||
|
} from "@/components/drive/richtext/docs-header-footer-region"
|
||||||
|
import { DocsBodyMarginMasks } from "@/components/drive/richtext/docs-body-margin-masks"
|
||||||
|
import {
|
||||||
|
DOCS_CANVAS_PADDING_TOP_NARROW_PX,
|
||||||
|
DOCS_CANVAS_PADDING_Y_PX,
|
||||||
|
DOCS_PAGE_GAP_PX,
|
||||||
|
} from "@/lib/drive/docs-page-layout-constants"
|
||||||
|
import {
|
||||||
|
effectiveMarginsPx,
|
||||||
|
} from "@/lib/drive/docs-header-footer-layout"
|
||||||
|
import {
|
||||||
|
computePageCount,
|
||||||
|
computePageMetrics,
|
||||||
|
computeProseMinHeight,
|
||||||
|
computeStackHeight,
|
||||||
|
} from "@/lib/drive/docs-page-metrics"
|
||||||
|
import { docsPageLengthToScreen, docsZoomToScale } from "@/lib/drive/docs-ruler-scale"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { focusEditorAtPointer } from "@/lib/drive/focus-editor-at-pointer"
|
import { focusEditorAtPointer } from "@/lib/drive/focus-editor-at-pointer"
|
||||||
|
import { applyPageFlowLayout, computeSimulatedLayoutHeight, readPageFlowMetrics } from "@/lib/drive/extensions/docs-page-flow-decoration"
|
||||||
|
|
||||||
const PAGE_GAP_PX = 12
|
/** Total layout height inside ProseMirror (blocks + flow spacers). */
|
||||||
|
|
||||||
/** Actual block layout height — ignores CSS min-height on ProseMirror (page stack). */
|
|
||||||
function measureProseContentHeight(prose: HTMLElement): number {
|
function measureProseContentHeight(prose: HTMLElement): number {
|
||||||
if (prose.childElementCount === 0) {
|
const metrics = readPageFlowMetrics(prose)
|
||||||
return 0
|
if (!metrics) return 0
|
||||||
|
|
||||||
|
const blocks: Array<{ height: number }> = []
|
||||||
|
for (const child of prose.children) {
|
||||||
|
const el = child as HTMLElement
|
||||||
|
if (el.classList.contains("docs-page-flow-spacer")) continue
|
||||||
|
const style = getComputedStyle(el)
|
||||||
|
const marginBottom = parseFloat(style.marginBottom) || 0
|
||||||
|
blocks.push({ height: el.offsetHeight + marginBottom })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (blocks.length === 0) return 0
|
||||||
|
|
||||||
|
const simulated = computeSimulatedLayoutHeight(
|
||||||
|
blocks,
|
||||||
|
metrics.bodyAreaH,
|
||||||
|
metrics.interPageSpacer
|
||||||
|
)
|
||||||
|
|
||||||
let maxBottom = 0
|
let maxBottom = 0
|
||||||
for (const child of prose.children) {
|
for (const child of prose.children) {
|
||||||
const el = child as HTMLElement
|
const el = child as HTMLElement
|
||||||
maxBottom = Math.max(maxBottom, el.offsetTop + el.offsetHeight)
|
maxBottom = Math.max(maxBottom, el.offsetTop + el.offsetHeight)
|
||||||
}
|
}
|
||||||
return maxBottom
|
|
||||||
|
return Math.max(simulated, maxBottom)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DocsPageViewInner({
|
function DocsPageViewInner({
|
||||||
@ -26,85 +64,217 @@ function DocsPageViewInner({
|
|||||||
pageLayout,
|
pageLayout,
|
||||||
zoom,
|
zoom,
|
||||||
editable,
|
editable,
|
||||||
|
showLayout,
|
||||||
|
showNonPrintableChars,
|
||||||
|
editorMode,
|
||||||
|
canvasRef: canvasRefProp,
|
||||||
onPageCountChange,
|
onPageCountChange,
|
||||||
|
onNarrowViewportChange,
|
||||||
|
onCanvasHeightChange,
|
||||||
|
onRegionContentChange,
|
||||||
|
onPageSetupChange,
|
||||||
|
onRegionEditorChange,
|
||||||
}: {
|
}: {
|
||||||
editor: Editor
|
editor: Editor
|
||||||
pageLayout: DocPageLayout
|
pageLayout: DocPageLayout
|
||||||
zoom: number
|
zoom: number
|
||||||
editable: boolean
|
editable: boolean
|
||||||
|
showLayout: boolean
|
||||||
|
showRuler: boolean
|
||||||
|
showNonPrintableChars: boolean
|
||||||
|
editorMode: "edit" | "suggest" | "view"
|
||||||
|
canvasRef?: RefObject<HTMLDivElement | null>
|
||||||
onPageCountChange?: (count: number) => void
|
onPageCountChange?: (count: number) => void
|
||||||
|
onNarrowViewportChange?: (narrow: boolean) => void
|
||||||
|
onCanvasHeightChange?: (height: number) => void
|
||||||
|
onRegionContentChange?: (
|
||||||
|
region: DocsHeaderFooterRegion,
|
||||||
|
content: Record<string, unknown>,
|
||||||
|
meta: { pageIndex: number; contentHeightPx: number }
|
||||||
|
) => void
|
||||||
|
onPageSetupChange?: (patch: Partial<DocPageSetup>) => void
|
||||||
|
onRegionEditorChange?: (editor: Editor | null) => void
|
||||||
}) {
|
}) {
|
||||||
const pageWidth = pageLayout.widthPx
|
const pageWidth = pageLayout.widthPx
|
||||||
const pageHeight = pageLayout.heightPx
|
const pageHeight = pageLayout.heightPx
|
||||||
const margins = pageLayout.marginsPx
|
const margins = pageLayout.marginsPx
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [pageCount, setPageCount] = useState(1)
|
const [pageCount, setPageCount] = useState(1)
|
||||||
const [narrowViewport, setNarrowViewport] = useState(false)
|
const [narrowViewport, setNarrowViewport] = useState(false)
|
||||||
|
const [editingTarget, setEditingTarget] = useState<DocsHeaderFooterEditTarget>(null)
|
||||||
|
const [pageRegionHeights, setPageRegionHeights] = useState<
|
||||||
|
Record<string, number>
|
||||||
|
>({})
|
||||||
|
|
||||||
|
const handleRegionHeightMeasure = useCallback(
|
||||||
|
(payload: {
|
||||||
|
region: DocsHeaderFooterRegion
|
||||||
|
pageIndex: number
|
||||||
|
heightPx: number
|
||||||
|
}) => {
|
||||||
|
const key = `${payload.region}-${payload.pageIndex}`
|
||||||
|
setPageRegionHeights((prev) => {
|
||||||
|
if (prev[key] === payload.heightPx) return prev
|
||||||
|
return { ...prev, [key]: payload.heightPx }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const measuredRegionHeights = useMemo(() => {
|
||||||
|
let header = 0
|
||||||
|
let footer = 0
|
||||||
|
for (const [key, heightPx] of Object.entries(pageRegionHeights)) {
|
||||||
|
if (key.startsWith("header-")) header = Math.max(header, heightPx)
|
||||||
|
if (key.startsWith("footer-")) footer = Math.max(footer, heightPx)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...(header > 0 ? { header } : {}),
|
||||||
|
...(footer > 0 ? { footer } : {}),
|
||||||
|
}
|
||||||
|
}, [pageRegionHeights])
|
||||||
|
|
||||||
|
const effectiveMargins = useMemo(
|
||||||
|
() =>
|
||||||
|
effectiveMarginsPx(
|
||||||
|
pageLayout,
|
||||||
|
null,
|
||||||
|
editingTarget ? undefined : measuredRegionHeights
|
||||||
|
),
|
||||||
|
[pageLayout, editingTarget, measuredRegionHeights]
|
||||||
|
)
|
||||||
|
const metrics = useMemo(
|
||||||
|
() => computePageMetrics({ ...pageLayout, effectiveMarginsPx: effectiveMargins }),
|
||||||
|
[pageLayout, effectiveMargins]
|
||||||
|
)
|
||||||
|
|
||||||
|
const localCanvasRef = useRef<HTMLDivElement>(null)
|
||||||
|
const canvasRef = canvasRefProp ?? localCanvasRef
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
const onPageCountChangeRef = useRef(onPageCountChange)
|
const onPageCountChangeRef = useRef(onPageCountChange)
|
||||||
onPageCountChangeRef.current = onPageCountChange
|
onPageCountChangeRef.current = onPageCountChange
|
||||||
|
|
||||||
const scale = zoom / 100
|
const scale = docsZoomToScale(zoom)
|
||||||
const scaledWidth = pageWidth * scale
|
const scaledWidth = docsPageLengthToScreen(pageWidth, scale)
|
||||||
|
|
||||||
|
const stopRegionEdit = useCallback(() => {
|
||||||
|
setEditingTarget(null)
|
||||||
|
onRegionEditorChange?.(null)
|
||||||
|
}, [onRegionEditorChange])
|
||||||
|
|
||||||
|
const startRegionEdit = useCallback(
|
||||||
|
(region: DocsHeaderFooterRegion, pageIndex: number) => {
|
||||||
|
setEditingTarget({ region, pageIndex })
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRegionEditorReady = useCallback(
|
||||||
|
(editor: Editor | null) => {
|
||||||
|
onRegionEditorChange?.(editor)
|
||||||
|
},
|
||||||
|
[onRegionEditorChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape" && editingTarget) {
|
||||||
|
event.preventDefault()
|
||||||
|
stopRegionEdit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey)
|
||||||
|
return () => window.removeEventListener("keydown", onKey)
|
||||||
|
}, [editingTarget, stopRegionEdit])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
if (!canvas) return
|
if (!canvas) return
|
||||||
|
|
||||||
const syncViewport = () => {
|
const syncViewport = () => {
|
||||||
setNarrowViewport(canvas.clientWidth < scaledWidth)
|
const narrow = canvas.clientWidth < scaledWidth
|
||||||
|
setNarrowViewport(narrow)
|
||||||
|
onNarrowViewportChange?.(narrow)
|
||||||
|
onCanvasHeightChange?.(canvas.clientHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
syncViewport()
|
syncViewport()
|
||||||
const ro = new ResizeObserver(syncViewport)
|
const ro = new ResizeObserver(syncViewport)
|
||||||
ro.observe(canvas)
|
ro.observe(canvas)
|
||||||
return () => ro.disconnect()
|
return () => ro.disconnect()
|
||||||
}, [scaledWidth])
|
}, [onCanvasHeightChange, onNarrowViewportChange, scaledWidth, canvasRef])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!showLayout) return
|
||||||
const surface = contentRef.current
|
const surface = contentRef.current
|
||||||
if (!surface) return
|
if (!surface) return
|
||||||
|
|
||||||
let rafId = 0
|
let debounceId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
const measure = () => {
|
const measurePageCount = () => {
|
||||||
const prose = surface.querySelector(".ProseMirror") as HTMLElement | null
|
const prose = surface.querySelector(".ProseMirror") as HTMLElement | null
|
||||||
if (!prose) return
|
if (!prose) return
|
||||||
|
|
||||||
const contentHeight = measureProseContentHeight(prose)
|
const contentHeight = measureProseContentHeight(prose)
|
||||||
const paddedHeight = margins.top + margins.bottom + contentHeight
|
const count = computePageCount(contentHeight, metrics)
|
||||||
const count = Math.max(1, Math.ceil(paddedHeight / pageHeight))
|
|
||||||
setPageCount((prev) => (prev === count ? prev : count))
|
setPageCount((prev) => (prev === count ? prev : count))
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleMeasure = () => {
|
const runLayoutPasses = (passesLeft: number) => {
|
||||||
if (rafId) cancelAnimationFrame(rafId)
|
if (cancelled || editor.isDestroyed) return
|
||||||
rafId = requestAnimationFrame(measure)
|
requestAnimationFrame(() => {
|
||||||
|
if (cancelled || editor.isDestroyed) return
|
||||||
|
const changed = applyPageFlowLayout(editor)
|
||||||
|
if (changed && passesLeft > 1) {
|
||||||
|
runLayoutPasses(passesLeft - 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
measurePageCount()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleMeasure()
|
let flushPending = false
|
||||||
const prose = surface.querySelector(".ProseMirror") as HTMLElement | null
|
const scheduleLayout = () => {
|
||||||
const ro = prose ? new ResizeObserver(scheduleMeasure) : null
|
if (!flushPending) {
|
||||||
if (prose && ro) ro.observe(prose)
|
flushPending = true
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
flushPending = false
|
||||||
|
runLayoutPasses(2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (debounceId) clearTimeout(debounceId)
|
||||||
|
debounceId = setTimeout(() => {
|
||||||
|
debounceId = null
|
||||||
|
runLayoutPasses(2)
|
||||||
|
}, 32)
|
||||||
|
}
|
||||||
|
|
||||||
const onTransaction = () => scheduleMeasure()
|
scheduleLayout()
|
||||||
|
|
||||||
|
const onTransaction = ({ transaction }: { transaction: { docChanged: boolean } }) => {
|
||||||
|
if (!transaction.docChanged) return
|
||||||
|
scheduleLayout()
|
||||||
|
}
|
||||||
editor.on("transaction", onTransaction)
|
editor.on("transaction", onTransaction)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (rafId) cancelAnimationFrame(rafId)
|
cancelled = true
|
||||||
ro?.disconnect()
|
if (debounceId) clearTimeout(debounceId)
|
||||||
editor.off("transaction", onTransaction)
|
editor.off("transaction", onTransaction)
|
||||||
}
|
}
|
||||||
}, [margins.bottom, margins.top, pageHeight, editor])
|
}, [editor, metrics, pageRegionHeights, showLayout])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onPageCountChangeRef.current?.(pageCount)
|
onPageCountChangeRef.current?.(pageCount)
|
||||||
}, [pageCount])
|
}, [pageCount])
|
||||||
|
|
||||||
const stackHeight = pageCount * pageHeight + (pageCount - 1) * PAGE_GAP_PX
|
const stackHeight = computeStackHeight(pageCount, pageHeight)
|
||||||
const innerMinHeight = Math.max(pageHeight - margins.top - margins.bottom, stackHeight - margins.top - margins.bottom)
|
const proseMinHeight = computeProseMinHeight(pageCount, metrics)
|
||||||
const scaledHeight = stackHeight * scale
|
|
||||||
const verticalPadding = narrowViewport ? 32 : 64
|
const scaledHeight = docsPageLengthToScreen(stackHeight, scale)
|
||||||
|
const verticalPaddingTop = narrowViewport
|
||||||
|
? DOCS_CANVAS_PADDING_TOP_NARROW_PX
|
||||||
|
: DOCS_CANVAS_PADDING_Y_PX
|
||||||
|
const verticalPaddingBottom = DOCS_CANVAS_PADDING_Y_PX
|
||||||
|
|
||||||
const textAreaBorderCss = pageLayout.textAreaBorderCss
|
const textAreaBorderCss = pageLayout.textAreaBorderCss
|
||||||
const sheetBorderCss = pageLayout.sheetBorderCss
|
const sheetBorderCss = pageLayout.sheetBorderCss
|
||||||
const pageBackground = pageLayout.pageColor
|
const pageBackground = pageLayout.pageColor
|
||||||
@ -161,72 +331,83 @@ function DocsPageViewInner({
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const bodyDimmed = editingTarget != null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={canvasRef} className={cn(
|
||||||
ref={canvasRef}
|
"ultidrive-docs-canvas h-full min-h-0 overflow-auto",
|
||||||
className="ultidrive-docs-canvas min-h-0 flex-1 overflow-auto bg-[#f9fbfd] dark:bg-[#202124]"
|
showLayout ? "bg-[#f9fbfd] dark:bg-[#202124]" : "bg-white dark:bg-background",
|
||||||
>
|
showNonPrintableChars && "docs-show-non-printable",
|
||||||
|
editorMode === "suggest" && "docs-editor-mode-suggest",
|
||||||
|
editorMode === "view" && "docs-editor-mode-view"
|
||||||
|
)}>
|
||||||
<div
|
<div
|
||||||
className={cn("mx-auto", narrowViewport ? "pb-8 pt-0" : "py-8")}
|
className="mx-auto"
|
||||||
style={{
|
style={{
|
||||||
width: scaledWidth,
|
width: scaledWidth,
|
||||||
minHeight: scaledHeight + verticalPadding,
|
paddingTop: verticalPaddingTop,
|
||||||
|
paddingBottom: verticalPaddingBottom,
|
||||||
|
minHeight: (showLayout ? scaledHeight : proseMinHeight + margins.top + margins.bottom) + verticalPaddingTop + verticalPaddingBottom,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="relative mx-auto"
|
className="relative mx-auto overflow-hidden"
|
||||||
style={{
|
style={{ width: scaledWidth, height: showLayout ? scaledHeight : undefined }}
|
||||||
width: scaledWidth,
|
|
||||||
height: scaledHeight,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute left-1/2 top-0 origin-top -translate-x-1/2"
|
data-docs-page-stack
|
||||||
|
className="absolute left-1/2 top-0 -translate-x-1/2 overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
width: pageWidth,
|
width: pageWidth,
|
||||||
height: stackHeight,
|
height: stackHeight,
|
||||||
transform: `scale(${scale})`,
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: "top center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Array.from({ length: pageCount }, (_, index) => (
|
{showLayout
|
||||||
<div
|
? Array.from({ length: pageCount }, (_, index) => {
|
||||||
key={index}
|
const pageTop = index * (pageHeight + DOCS_PAGE_GAP_PX)
|
||||||
className={cn(
|
return (
|
||||||
"ultidrive-docs-page absolute left-0 overflow-hidden dark:bg-white",
|
<div
|
||||||
sheetBorderCss && "ultidrive-docs-page--imported-border"
|
key={index}
|
||||||
)}
|
className={cn(
|
||||||
style={{
|
"ultidrive-docs-page absolute left-0 overflow-hidden dark:bg-white",
|
||||||
top: index * (pageHeight + PAGE_GAP_PX),
|
sheetBorderCss && "ultidrive-docs-page--imported-border"
|
||||||
width: pageWidth,
|
)}
|
||||||
height: pageHeight,
|
style={{
|
||||||
backgroundColor: pageBackground,
|
top: pageTop,
|
||||||
boxShadow:
|
width: pageWidth,
|
||||||
"0 1px 3px 1px rgba(60,64,67,.15), 0 1px 2px 0 rgba(60,64,67,.3)",
|
height: pageHeight,
|
||||||
...(sheetBorderCss
|
backgroundColor: pageBackground,
|
||||||
? {
|
boxShadow:
|
||||||
borderTop: sheetBorderCss.top ?? "none",
|
"0 1px 3px 1px rgba(60,64,67,.15), 0 1px 2px 0 rgba(60,64,67,.3)",
|
||||||
borderRight: sheetBorderCss.right ?? "none",
|
...(sheetBorderCss
|
||||||
borderBottom: sheetBorderCss.bottom ?? "none",
|
? {
|
||||||
borderLeft: sheetBorderCss.left ?? "none",
|
borderTop: sheetBorderCss.top ?? "none",
|
||||||
}
|
borderRight: sheetBorderCss.right ?? "none",
|
||||||
: {}),
|
borderBottom: sheetBorderCss.bottom ?? "none",
|
||||||
}}
|
borderLeft: sheetBorderCss.left ?? "none",
|
||||||
aria-hidden
|
}
|
||||||
>
|
: {}),
|
||||||
{renderPageBackground(index)}
|
}}
|
||||||
</div>
|
aria-hidden
|
||||||
))}
|
>
|
||||||
|
{renderPageBackground(index)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
|
||||||
{textAreaBorderCss
|
{showLayout && textAreaBorderCss
|
||||||
? Array.from({ length: pageCount }, (_, index) => (
|
? Array.from({ length: pageCount }, (_, index) => (
|
||||||
<div
|
<div
|
||||||
key={`text-border-${index}`}
|
key={`text-border-${index}`}
|
||||||
className="pointer-events-none absolute z-[5] box-border"
|
className="pointer-events-none absolute z-[5] box-border"
|
||||||
style={{
|
style={{
|
||||||
top: index * (pageHeight + PAGE_GAP_PX) + margins.top,
|
top: index * (pageHeight + DOCS_PAGE_GAP_PX) + effectiveMargins.top,
|
||||||
left: margins.left,
|
left: effectiveMargins.left,
|
||||||
width: pageWidth - margins.left - margins.right,
|
width: pageWidth - effectiveMargins.left - effectiveMargins.right,
|
||||||
height: pageHeight - margins.top - margins.bottom,
|
height: pageHeight - effectiveMargins.top - effectiveMargins.bottom,
|
||||||
borderTop: textAreaBorderCss.top ?? "none",
|
borderTop: textAreaBorderCss.top ?? "none",
|
||||||
borderRight: textAreaBorderCss.right ?? "none",
|
borderRight: textAreaBorderCss.right ?? "none",
|
||||||
borderBottom: textAreaBorderCss.bottom ?? "none",
|
borderBottom: textAreaBorderCss.bottom ?? "none",
|
||||||
@ -237,16 +418,114 @@ function DocsPageViewInner({
|
|||||||
))
|
))
|
||||||
: null}
|
: null}
|
||||||
|
|
||||||
|
{showLayout ? (
|
||||||
|
<DocsBodyMarginMasks
|
||||||
|
pageCount={pageCount}
|
||||||
|
pageLayout={pageLayout}
|
||||||
|
pageWidth={pageWidth}
|
||||||
|
pageHeight={pageHeight}
|
||||||
|
pageColor={pageBackground}
|
||||||
|
pageRegionHeights={pageRegionHeights}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showLayout
|
||||||
|
? Array.from({ length: pageCount }, (_, index) => {
|
||||||
|
const pageTop = index * (pageHeight + DOCS_PAGE_GAP_PX)
|
||||||
|
return (
|
||||||
|
<div key={`hf-${index}`}>
|
||||||
|
<DocsHeaderFooterBand
|
||||||
|
region="header"
|
||||||
|
pageLayout={pageLayout}
|
||||||
|
pageIndex={index}
|
||||||
|
pageTop={pageTop}
|
||||||
|
pageWidth={pageWidth}
|
||||||
|
pageHeight={pageHeight}
|
||||||
|
margins={margins}
|
||||||
|
editable={editable}
|
||||||
|
editingTarget={editingTarget}
|
||||||
|
onStartEdit={startRegionEdit}
|
||||||
|
onStopEdit={stopRegionEdit}
|
||||||
|
onContentChange={(region, content, meta) =>
|
||||||
|
onRegionContentChange?.(region, content, meta)
|
||||||
|
}
|
||||||
|
onPageSetupChange={(patch) => onPageSetupChange?.(patch)}
|
||||||
|
onRegionHeightMeasure={handleRegionHeightMeasure}
|
||||||
|
onRegionEditorReady={
|
||||||
|
editingTarget?.region === "header" &&
|
||||||
|
editingTarget.pageIndex === index
|
||||||
|
? handleRegionEditorReady
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DocsHeaderFooterBand
|
||||||
|
region="footer"
|
||||||
|
pageLayout={pageLayout}
|
||||||
|
pageIndex={index}
|
||||||
|
pageTop={pageTop}
|
||||||
|
pageWidth={pageWidth}
|
||||||
|
pageHeight={pageHeight}
|
||||||
|
margins={margins}
|
||||||
|
editable={editable}
|
||||||
|
editingTarget={editingTarget}
|
||||||
|
onStartEdit={startRegionEdit}
|
||||||
|
onStopEdit={stopRegionEdit}
|
||||||
|
onContentChange={(region, content, meta) =>
|
||||||
|
onRegionContentChange?.(region, content, meta)
|
||||||
|
}
|
||||||
|
onPageSetupChange={(patch) => onPageSetupChange?.(patch)}
|
||||||
|
onRegionHeightMeasure={handleRegionHeightMeasure}
|
||||||
|
onRegionEditorReady={
|
||||||
|
editingTarget?.region === "footer" &&
|
||||||
|
editingTarget.pageIndex === index
|
||||||
|
? handleRegionEditorReady
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
|
||||||
|
{bodyDimmed ? (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 z-[12] bg-[#e8eaed]/40"
|
||||||
|
style={{ height: stackHeight }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className="ultidrive-docs-editor-surface relative z-10"
|
className={cn(
|
||||||
|
"ultidrive-docs-editor-surface relative",
|
||||||
|
showLayout && "ultidrive-docs-editor-surface--paginated",
|
||||||
|
!showLayout && "ultidrive-docs-editor-surface--compact",
|
||||||
|
bodyDimmed && "ultidrive-docs-editor-surface--dimmed"
|
||||||
|
)}
|
||||||
style={{
|
style={{
|
||||||
padding: `${margins.top}px ${margins.right}px ${margins.bottom}px ${margins.left}px`,
|
padding: `${effectiveMargins.top}px ${effectiveMargins.right}px ${effectiveMargins.bottom}px ${effectiveMargins.left}px`,
|
||||||
minHeight: stackHeight,
|
height: showLayout ? stackHeight : undefined,
|
||||||
["--docs-prose-min-height" as string]: `${innerMinHeight}px`,
|
minHeight: showLayout
|
||||||
|
? undefined
|
||||||
|
: proseMinHeight + effectiveMargins.top + effectiveMargins.bottom,
|
||||||
|
["--docs-stack-height" as string]: `${stackHeight}px`,
|
||||||
|
["--docs-prose-min-height" as string]: `${proseMinHeight}px`,
|
||||||
|
["--docs-body-area-h" as string]: `${metrics.bodyAreaHeight}px`,
|
||||||
|
["--docs-inter-page-spacer" as string]: `${metrics.interPageSpacer}px`,
|
||||||
}}
|
}}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (!editable) return
|
if (bodyDimmed) {
|
||||||
|
const target = event.target as HTMLElement
|
||||||
|
if (
|
||||||
|
!target.closest(".docs-hf-band") &&
|
||||||
|
!target.closest(".docs-hf-chrome")
|
||||||
|
) {
|
||||||
|
stopRegionEdit()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!editable || editingTarget) return
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
if (target.closest(".ProseMirror")) return
|
if (target.closest(".ProseMirror")) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@ -270,12 +549,16 @@ export const DocsPageView = memo(DocsPageViewInner)
|
|||||||
export function DocsStatusBar({
|
export function DocsStatusBar({
|
||||||
pageLayout,
|
pageLayout,
|
||||||
pageCount,
|
pageCount,
|
||||||
|
currentPage = 1,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
pageLayout: DocPageLayout
|
pageLayout: DocPageLayout
|
||||||
pageCount: number
|
pageCount: number
|
||||||
|
currentPage?: number
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
|
const pageLabel = Math.min(Math.max(1, currentPage), Math.max(1, pageCount))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -283,7 +566,9 @@ export function DocsStatusBar({
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>Page 1 sur {pageCount}</span>
|
<span>
|
||||||
|
Page {pageLabel} sur {pageCount}
|
||||||
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{pageLayout.format.label} ({pageLayout.format.widthMm} × {pageLayout.format.heightMm} mm)
|
{pageLayout.format.label} ({pageLayout.format.widthMm} × {pageLayout.format.heightMm} mm)
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
135
components/drive/richtext/docs-region-editor.tsx
Normal file
135
components/drive/richtext/docs-region-editor.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
import { useEditor, EditorContent, type Editor } from "@tiptap/react"
|
||||||
|
import {
|
||||||
|
buildRegionEditorExtensions,
|
||||||
|
RICHTEXT_REGION_EDITOR_CLASS,
|
||||||
|
} from "@/lib/drive/richtext-extensions"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function regionEditorProseStyle(maxHeightPx?: number, minHeightPx?: number): string | undefined {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (minHeightPx != null) parts.push(`min-height:${minHeightPx}px`)
|
||||||
|
if (maxHeightPx != null) {
|
||||||
|
parts.push(`max-height:${maxHeightPx}px`, "overflow-y:auto")
|
||||||
|
}
|
||||||
|
return parts.length > 0 ? parts.join(";") : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocsRegionEditor({
|
||||||
|
content,
|
||||||
|
editable,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
maxHeightPx,
|
||||||
|
minHeightPx,
|
||||||
|
onUpdate,
|
||||||
|
onBlur,
|
||||||
|
onEditorReady,
|
||||||
|
onContentHeightChange,
|
||||||
|
autoFocus,
|
||||||
|
}: {
|
||||||
|
content: Record<string, unknown> | undefined
|
||||||
|
editable: boolean
|
||||||
|
placeholder?: string
|
||||||
|
className?: string
|
||||||
|
/** When set, content scrolls inside. Omit to grow with content. */
|
||||||
|
maxHeightPx?: number
|
||||||
|
/** Minimum editable band height (header/footer margin zone). */
|
||||||
|
minHeightPx?: number
|
||||||
|
onUpdate?: (json: Record<string, unknown>) => void
|
||||||
|
onBlur?: () => void
|
||||||
|
onEditorReady?: (editor: Editor | null) => void
|
||||||
|
onContentHeightChange?: (height: number) => void
|
||||||
|
autoFocus?: boolean
|
||||||
|
}) {
|
||||||
|
const syncingRef = useRef(false)
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
const proseStyle = regionEditorProseStyle(maxHeightPx, minHeightPx)
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
immediatelyRender: false,
|
||||||
|
editable,
|
||||||
|
extensions: buildRegionEditorExtensions(placeholder),
|
||||||
|
content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: RICHTEXT_REGION_EDITOR_CLASS,
|
||||||
|
...(proseStyle ? { style: proseStyle } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onUpdate: ({ editor: ed }) => {
|
||||||
|
if (syncingRef.current) return
|
||||||
|
onUpdate?.(ed.getJSON() as Record<string, unknown>)
|
||||||
|
},
|
||||||
|
onBlur: () => onBlur?.(),
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onEditorReady?.(editor)
|
||||||
|
return () => onEditorReady?.(null)
|
||||||
|
}, [editor, onEditorReady])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || !content) return
|
||||||
|
syncingRef.current = true
|
||||||
|
editor.commands.setContent(content, { emitUpdate: false })
|
||||||
|
syncingRef.current = false
|
||||||
|
}, [content, editor])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
editor?.setEditable(editable)
|
||||||
|
if (editable && autoFocus) {
|
||||||
|
requestAnimationFrame(() => editor?.commands.focus("end"))
|
||||||
|
}
|
||||||
|
}, [autoFocus, editable, editor])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return
|
||||||
|
const style = regionEditorProseStyle(maxHeightPx, minHeightPx)
|
||||||
|
editor.setOptions({
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: RICHTEXT_REGION_EDITOR_CLASS,
|
||||||
|
...(style ? { style } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [editor, maxHeightPx, minHeightPx])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = rootRef.current
|
||||||
|
if (!root || !onContentHeightChange) return
|
||||||
|
const prose = root.querySelector(".ProseMirror") as HTMLElement | null
|
||||||
|
if (!prose) return
|
||||||
|
|
||||||
|
const report = () => {
|
||||||
|
requestAnimationFrame(() => onContentHeightChange(prose.offsetHeight))
|
||||||
|
}
|
||||||
|
report()
|
||||||
|
const ro = new ResizeObserver(report)
|
||||||
|
ro.observe(prose)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [editor, onContentHeightChange])
|
||||||
|
|
||||||
|
if (!editor) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn("docs-region-editor-root", maxHeightPx == null && "docs-region-editor-root--grow")}
|
||||||
|
style={minHeightPx != null ? { minHeight: minHeightPx } : undefined}
|
||||||
|
>
|
||||||
|
<EditorContent
|
||||||
|
editor={editor}
|
||||||
|
className={cn("docs-region-editor", maxHeightPx == null ? "docs-region-editor--grow" : "h-full", className)}
|
||||||
|
style={
|
||||||
|
maxHeightPx != null
|
||||||
|
? { height: maxHeightPx, maxHeight: maxHeightPx }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
216
components/drive/richtext/docs-ruler-markers.tsx
Normal file
216
components/drive/richtext/docs-ruler-markers.tsx
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { memo, useRef, type ReactNode } from "react"
|
||||||
|
import { createPortal } from "react-dom"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export type DocsRulerDragTooltipState = {
|
||||||
|
label: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
} | null
|
||||||
|
|
||||||
|
export const DocsRulerMarginDragTooltip = memo(function DocsRulerMarginDragTooltip({
|
||||||
|
tooltip,
|
||||||
|
}: {
|
||||||
|
tooltip: DocsRulerDragTooltipState
|
||||||
|
}) {
|
||||||
|
if (!tooltip || typeof document === "undefined") return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="docs-ruler-margin-tooltip pointer-events-none fixed z-[200] rounded px-2 py-1 text-xs font-medium tabular-nums text-white shadow-md"
|
||||||
|
style={{
|
||||||
|
left: tooltip.x + 12,
|
||||||
|
top: tooltip.y + 12,
|
||||||
|
}}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{tooltip.label}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Blue downward triangle (margin / left indent). */
|
||||||
|
export const DocsRulerTriangleMarker = memo(function DocsRulerTriangleMarker({
|
||||||
|
left,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
left: number
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={className} style={{ left }} aria-hidden>
|
||||||
|
<svg width="8" height="6" viewBox="0 0 8 6" className="block">
|
||||||
|
<path d="M0 0 L8 0 L4 6 Z" fill="#1a73e8" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Blue rectangle on horizontal ruler (first-line indent). */
|
||||||
|
export const DocsRulerFirstLineMarker = memo(function DocsRulerFirstLineMarker({
|
||||||
|
left,
|
||||||
|
}: {
|
||||||
|
left: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute top-0 h-[5px] w-[6px] -translate-x-1/2 rounded-[1px] bg-[#1a73e8]"
|
||||||
|
style={{ left }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Blue upward triangle on vertical ruler (top margin). */
|
||||||
|
export const DocsRulerUpTriangleMarker = memo(function DocsRulerUpTriangleMarker({
|
||||||
|
top,
|
||||||
|
}: {
|
||||||
|
top: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute left-1/2 -translate-x-1/2"
|
||||||
|
style={{ top }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<svg width="6" height="8" viewBox="0 0 6 8" className="block">
|
||||||
|
<path d="M0 8 L6 8 L3 0 Z" fill="#1a73e8" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Blue downward triangle on vertical ruler (bottom margin). */
|
||||||
|
export const DocsRulerDownTriangleMarker = memo(function DocsRulerDownTriangleMarker({
|
||||||
|
top,
|
||||||
|
}: {
|
||||||
|
top: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute left-1/2 -translate-x-1/2 -translate-y-full"
|
||||||
|
style={{ top }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<svg width="6" height="8" viewBox="0 0 6 8" className="block">
|
||||||
|
<path d="M0 0 L6 0 L3 8 Z" fill="#1a73e8" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DocsRulerDraggableHandle = memo(function DocsRulerDraggableHandle({
|
||||||
|
style,
|
||||||
|
className,
|
||||||
|
disabled,
|
||||||
|
axis,
|
||||||
|
ariaLabel,
|
||||||
|
onPointerDown,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
style: React.CSSProperties
|
||||||
|
className?: string
|
||||||
|
disabled?: boolean
|
||||||
|
axis: "horizontal" | "vertical"
|
||||||
|
ariaLabel: string
|
||||||
|
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="slider"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-disabled={disabled || undefined}
|
||||||
|
className={cn(
|
||||||
|
"docs-ruler-drag-handle absolute touch-none select-none",
|
||||||
|
axis === "horizontal"
|
||||||
|
? "bottom-0 -translate-x-1/2 cursor-ew-resize"
|
||||||
|
: "left-1/2 -translate-x-1/2 cursor-ns-resize",
|
||||||
|
disabled ? "pointer-events-none" : "pointer-events-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
onPointerDown={disabled ? undefined : onPointerDown}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center",
|
||||||
|
axis === "horizontal" ? "h-5 w-4 -mb-0.5" : "h-4 w-5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useRulerPointerDrag({
|
||||||
|
rulerRef,
|
||||||
|
axis,
|
||||||
|
disabled,
|
||||||
|
onDrag,
|
||||||
|
onDragEnd,
|
||||||
|
}: {
|
||||||
|
rulerRef: React.RefObject<HTMLElement | null>
|
||||||
|
axis: "horizontal" | "vertical"
|
||||||
|
disabled?: boolean
|
||||||
|
onDrag: (pagePx: number, clientX: number, clientY: number) => void
|
||||||
|
onDragEnd: () => void
|
||||||
|
}) {
|
||||||
|
const draggingRef = useRef(false)
|
||||||
|
|
||||||
|
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (disabled) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
const handle = event.currentTarget
|
||||||
|
handle.setPointerCapture(event.pointerId)
|
||||||
|
draggingRef.current = true
|
||||||
|
document.body.style.userSelect = "none"
|
||||||
|
document.body.style.cursor = axis === "horizontal" ? "ew-resize" : "ns-resize"
|
||||||
|
|
||||||
|
const readPagePx = (clientX: number, clientY: number) => {
|
||||||
|
const ruler = rulerRef.current
|
||||||
|
if (!ruler) return null
|
||||||
|
const rect = ruler.getBoundingClientRect()
|
||||||
|
const scaleAttr = ruler.dataset.docsRulerScale
|
||||||
|
const scale = scaleAttr ? Number.parseFloat(scaleAttr) : 1
|
||||||
|
if (!Number.isFinite(scale) || scale <= 0) return null
|
||||||
|
return axis === "horizontal"
|
||||||
|
? (clientX - rect.left) / scale
|
||||||
|
: (clientY - rect.top) / scale
|
||||||
|
}
|
||||||
|
|
||||||
|
const move = (clientX: number, clientY: number) => {
|
||||||
|
const pagePx = readPagePx(clientX, clientY)
|
||||||
|
if (pagePx != null) onDrag(pagePx, clientX, clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
move(event.clientX, event.clientY)
|
||||||
|
|
||||||
|
const onMove = (ev: PointerEvent) => move(ev.clientX, ev.clientY)
|
||||||
|
const onUp = (ev: PointerEvent) => {
|
||||||
|
draggingRef.current = false
|
||||||
|
document.body.style.userSelect = ""
|
||||||
|
document.body.style.cursor = ""
|
||||||
|
if (handle.hasPointerCapture(ev.pointerId)) {
|
||||||
|
handle.releasePointerCapture(ev.pointerId)
|
||||||
|
}
|
||||||
|
window.removeEventListener("pointermove", onMove)
|
||||||
|
window.removeEventListener("pointerup", onUp)
|
||||||
|
window.removeEventListener("pointercancel", onUp)
|
||||||
|
onDragEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", onMove)
|
||||||
|
window.addEventListener("pointerup", onUp)
|
||||||
|
window.addEventListener("pointercancel", onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { onPointerDown, draggingRef }
|
||||||
|
}
|
||||||
137
components/drive/richtext/docs-rulers-chrome.tsx
Normal file
137
components/drive/richtext/docs-rulers-chrome.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { RefObject } from "react"
|
||||||
|
import { ListTree } from "lucide-react"
|
||||||
|
import { DocsHorizontalRuler } from "@/components/drive/richtext/docs-horizontal-ruler"
|
||||||
|
import { DocsVerticalRuler } from "@/components/drive/richtext/docs-vertical-ruler"
|
||||||
|
import type { DocPageLayout } from "@/lib/drive/doc-page-setup"
|
||||||
|
import type { DocsRulerMarginSide } from "@/lib/drive/docs-ruler-margin-math"
|
||||||
|
import {
|
||||||
|
DOCS_HORIZONTAL_RULER_HEIGHT_PX,
|
||||||
|
DOCS_VERTICAL_RULER_WIDTH_PX,
|
||||||
|
} from "@/lib/drive/docs-page-layout-constants"
|
||||||
|
import { docsPageLengthToScreen } from "@/lib/drive/docs-ruler-scale"
|
||||||
|
import type { DocsRulerSyncState } from "@/lib/drive/use-docs-ruler-sync"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export function DocsRulerToolbarRow({
|
||||||
|
pageLayout,
|
||||||
|
scale,
|
||||||
|
rulerSync,
|
||||||
|
rulerTrackRef,
|
||||||
|
outlineExpanded,
|
||||||
|
onToggleOutline,
|
||||||
|
editable,
|
||||||
|
onMarginDragStart,
|
||||||
|
onMarginDrag,
|
||||||
|
onMarginDragEnd,
|
||||||
|
}: {
|
||||||
|
pageLayout: DocPageLayout
|
||||||
|
scale: number
|
||||||
|
rulerSync: DocsRulerSyncState
|
||||||
|
rulerTrackRef: RefObject<HTMLDivElement | null>
|
||||||
|
outlineExpanded?: boolean
|
||||||
|
onToggleOutline?: () => void
|
||||||
|
editable?: boolean
|
||||||
|
onMarginDragStart?: (side: DocsRulerMarginSide) => void
|
||||||
|
onMarginDrag?: (side: DocsRulerMarginSide, pagePx: number, clientX: number, clientY: number) => void
|
||||||
|
onMarginDragEnd?: () => void
|
||||||
|
}) {
|
||||||
|
const scaledPageWidth = docsPageLengthToScreen(pageLayout.widthPx, scale)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="docs-toolbar-ruler-row flex shrink-0 bg-transparent"
|
||||||
|
style={{ height: DOCS_HORIZONTAL_RULER_HEIGHT_PX }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-end justify-center border-r border-[#dadce0]/80 bg-transparent pb-0.5 dark:border-border/80"
|
||||||
|
style={{
|
||||||
|
width: DOCS_VERTICAL_RULER_WIDTH_PX,
|
||||||
|
height: DOCS_HORIZONTAL_RULER_HEIGHT_PX,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto flex size-7 items-center justify-center rounded-full border border-[#dadce0] bg-white text-[#5f6368] shadow-sm transition-colors hover:bg-[#e8eaed] dark:border-border dark:bg-background dark:text-muted-foreground dark:hover:bg-muted",
|
||||||
|
outlineExpanded && "border-[#1a73e8] text-[#1a73e8]"
|
||||||
|
)}
|
||||||
|
aria-label="Afficher le plan du document"
|
||||||
|
aria-pressed={outlineExpanded}
|
||||||
|
onClick={onToggleOutline}
|
||||||
|
>
|
||||||
|
<ListTree className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={rulerTrackRef}
|
||||||
|
className="relative min-w-0 flex-1 overflow-visible"
|
||||||
|
style={{
|
||||||
|
height: DOCS_HORIZONTAL_RULER_HEIGHT_PX,
|
||||||
|
paddingRight: rulerSync.canvasScrollbarWidth,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mx-auto"
|
||||||
|
style={{
|
||||||
|
width: scaledPageWidth,
|
||||||
|
transform: rulerSync.canvasScrollLeft
|
||||||
|
? `translateX(${-rulerSync.canvasScrollLeft}px)`
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DocsHorizontalRuler
|
||||||
|
pageLayout={pageLayout}
|
||||||
|
scale={scale}
|
||||||
|
indents={rulerSync.indents}
|
||||||
|
editable={editable}
|
||||||
|
onMarginDragStart={onMarginDragStart}
|
||||||
|
onMarginDrag={onMarginDrag}
|
||||||
|
onMarginDragEnd={onMarginDragEnd}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocsRulersLeftRail({
|
||||||
|
pageLayout,
|
||||||
|
scale,
|
||||||
|
rulerSync,
|
||||||
|
editable,
|
||||||
|
onMarginDragStart,
|
||||||
|
onMarginDrag,
|
||||||
|
onMarginDragEnd,
|
||||||
|
}: {
|
||||||
|
pageLayout: DocPageLayout
|
||||||
|
scale: number
|
||||||
|
rulerSync: DocsRulerSyncState
|
||||||
|
editable?: boolean
|
||||||
|
onMarginDragStart?: (side: DocsRulerMarginSide) => void
|
||||||
|
onMarginDrag?: (side: DocsRulerMarginSide, pagePx: number, clientX: number, clientY: number) => void
|
||||||
|
onMarginDragEnd?: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"docs-rulers-left-rail absolute bottom-0 left-0 top-0 z-20 overflow-visible bg-transparent",
|
||||||
|
!editable && "pointer-events-none"
|
||||||
|
)}
|
||||||
|
style={{ width: DOCS_VERTICAL_RULER_WIDTH_PX }}
|
||||||
|
>
|
||||||
|
<div className="pointer-events-auto absolute left-0" style={{ top: rulerSync.pageTopInViewport }}>
|
||||||
|
<DocsVerticalRuler
|
||||||
|
pageLayout={pageLayout}
|
||||||
|
scale={scale}
|
||||||
|
editable={editable}
|
||||||
|
onMarginDragStart={onMarginDragStart}
|
||||||
|
onMarginDrag={onMarginDrag}
|
||||||
|
onMarginDragEnd={onMarginDragEnd}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { memo, useCallback, useMemo, useRef, useState } from "react"
|
import { memo, useCallback, useMemo, useState } from "react"
|
||||||
import { Icon } from "@iconify/react"
|
import { Icon } from "@iconify/react"
|
||||||
import type { Editor } from "@tiptap/react"
|
import type { Editor } from "@tiptap/react"
|
||||||
import {
|
import {
|
||||||
@ -11,7 +11,6 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Bold,
|
Bold,
|
||||||
Image as ImageIcon,
|
|
||||||
Italic,
|
Italic,
|
||||||
Link2,
|
Link2,
|
||||||
List,
|
List,
|
||||||
@ -55,6 +54,12 @@ import {
|
|||||||
DOCS_FONT_FAMILIES,
|
DOCS_FONT_FAMILIES,
|
||||||
type DocsFontFamilyName,
|
type DocsFontFamilyName,
|
||||||
} from "@/lib/drive/docs-font-family"
|
} from "@/lib/drive/docs-font-family"
|
||||||
|
import {
|
||||||
|
DocsGraphicInsertMenu,
|
||||||
|
DocsGraphicLayoutMenu,
|
||||||
|
readGraphicToolbarActive,
|
||||||
|
} from "@/components/drive/richtext/docs-graphic-toolbar-menu"
|
||||||
|
import { DocsGraphicOptionsPanel } from "@/components/drive/richtext/docs-graphic-options-panel"
|
||||||
import { useDocsToolbarState } from "@/lib/drive/use-docs-toolbar-state"
|
import { useDocsToolbarState } from "@/lib/drive/use-docs-toolbar-state"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@ -125,6 +130,7 @@ function DocsToolbarInner({
|
|||||||
showChromeToggle,
|
showChromeToggle,
|
||||||
chromeCollapsed,
|
chromeCollapsed,
|
||||||
onToggleChromeCollapsed,
|
onToggleChromeCollapsed,
|
||||||
|
embedded,
|
||||||
}: {
|
}: {
|
||||||
editor: Editor | null
|
editor: Editor | null
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
@ -135,24 +141,13 @@ function DocsToolbarInner({
|
|||||||
showChromeToggle?: boolean
|
showChromeToggle?: boolean
|
||||||
chromeCollapsed?: boolean
|
chromeCollapsed?: boolean
|
||||||
onToggleChromeCollapsed?: () => void
|
onToggleChromeCollapsed?: () => void
|
||||||
|
/** Rendered inside DocsEditorWorkspace shell (no outer docs-toolbar-shell). */
|
||||||
|
embedded?: boolean
|
||||||
}) {
|
}) {
|
||||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const [linkOpen, setLinkOpen] = useState(false)
|
const [linkOpen, setLinkOpen] = useState(false)
|
||||||
const [linkUrl, setLinkUrl] = useState("")
|
const [linkUrl, setLinkUrl] = useState("")
|
||||||
const toolbarState = useDocsToolbarState(editor)
|
const toolbarState = useDocsToolbarState(editor)
|
||||||
|
const graphicSelected = readGraphicToolbarActive(editor)
|
||||||
const insertImage = useCallback(
|
|
||||||
(file: File) => {
|
|
||||||
if (!editor) return
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = () => {
|
|
||||||
const src = reader.result as string
|
|
||||||
editor.chain().focus().setImage({ src }).run()
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
},
|
|
||||||
[editor]
|
|
||||||
)
|
|
||||||
|
|
||||||
const applyLink = useCallback(() => {
|
const applyLink = useCallback(() => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
@ -473,16 +468,18 @@ function DocsToolbarInner({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "insert-image",
|
id: "insert-graphic",
|
||||||
sepAfter: true,
|
sepAfter: true,
|
||||||
node: (
|
node: (
|
||||||
<ToolbarIconBtn
|
<>
|
||||||
disabled={disabled}
|
<DocsGraphicInsertMenu editor={editor} disabled={disabled} />
|
||||||
label="Insérer une image"
|
{graphicSelected ? (
|
||||||
onClick={() => imageInputRef.current?.click()}
|
<>
|
||||||
>
|
<DocsGraphicLayoutMenu editor={editor} disabled={disabled} />
|
||||||
<ImageIcon className="size-4" />
|
<DocsGraphicOptionsPanel editor={editor} disabled={disabled} />
|
||||||
</ToolbarIconBtn>
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -597,6 +594,7 @@ function DocsToolbarInner({
|
|||||||
onZoomChange,
|
onZoomChange,
|
||||||
spellcheck,
|
spellcheck,
|
||||||
onToggleSpellcheck,
|
onToggleSpellcheck,
|
||||||
|
graphicSelected,
|
||||||
linkOpen,
|
linkOpen,
|
||||||
linkUrl,
|
linkUrl,
|
||||||
applyLink,
|
applyLink,
|
||||||
@ -611,13 +609,7 @@ function DocsToolbarInner({
|
|||||||
const visibleSegments = segments.slice(0, visibleCount)
|
const visibleSegments = segments.slice(0, visibleCount)
|
||||||
const overflowSegments = segments.slice(visibleCount)
|
const overflowSegments = segments.slice(visibleCount)
|
||||||
|
|
||||||
return (
|
const toolbarRow = (
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"docs-toolbar-shell shrink-0",
|
|
||||||
chromeCollapsed && "docs-toolbar-shell--collapsed"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="docs-toolbar relative flex items-center gap-0 overflow-hidden px-1.5 py-0.5"
|
className="docs-toolbar relative flex items-center gap-0 overflow-hidden px-1.5 py-0.5"
|
||||||
@ -693,19 +685,19 @@ function DocsToolbarInner({
|
|||||||
</ToolbarIconBtn>
|
</ToolbarIconBtn>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<input
|
|
||||||
ref={imageInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (file) insertImage(file)
|
|
||||||
e.target.value = ""
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (embedded) return toolbarRow
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"docs-toolbar-shell shrink-0",
|
||||||
|
chromeCollapsed && "docs-toolbar-shell--collapsed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{toolbarRow}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
130
components/drive/richtext/docs-vertical-ruler.tsx
Normal file
130
components/drive/richtext/docs-vertical-ruler.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { memo, useRef } from "react"
|
||||||
|
import type { DocPageLayout } from "@/lib/drive/doc-page-setup"
|
||||||
|
import { DOCS_VERTICAL_RULER_WIDTH_PX } from "@/lib/drive/docs-page-layout-constants"
|
||||||
|
import { docsPageLengthToScreen } from "@/lib/drive/docs-ruler-scale"
|
||||||
|
import { buildVerticalRulerTicks } from "@/lib/drive/docs-ruler-math"
|
||||||
|
import type { DocsRulerMarginSide } from "@/lib/drive/docs-ruler-margin-math"
|
||||||
|
import {
|
||||||
|
DocsRulerDownTriangleMarker,
|
||||||
|
DocsRulerDraggableHandle,
|
||||||
|
DocsRulerUpTriangleMarker,
|
||||||
|
useRulerPointerDrag,
|
||||||
|
} from "@/components/drive/richtext/docs-ruler-markers"
|
||||||
|
|
||||||
|
function DocsVerticalRulerInner({
|
||||||
|
pageLayout,
|
||||||
|
scale,
|
||||||
|
editable,
|
||||||
|
onMarginDragStart,
|
||||||
|
onMarginDrag,
|
||||||
|
onMarginDragEnd,
|
||||||
|
}: {
|
||||||
|
pageLayout: DocPageLayout
|
||||||
|
scale: number
|
||||||
|
editable?: boolean
|
||||||
|
onMarginDragStart?: (side: DocsRulerMarginSide) => void
|
||||||
|
onMarginDrag?: (side: DocsRulerMarginSide, pagePx: number, clientX: number, clientY: number) => void
|
||||||
|
onMarginDragEnd?: () => void
|
||||||
|
}) {
|
||||||
|
const rulerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const pageHeight = pageLayout.heightPx
|
||||||
|
const margins = pageLayout.marginsPx
|
||||||
|
const scaledHeight = docsPageLengthToScreen(pageHeight, scale)
|
||||||
|
const ticks = buildVerticalRulerTicks(pageHeight, margins.top, pageLayout.format.id)
|
||||||
|
const s = (px: number) => docsPageLengthToScreen(px, scale)
|
||||||
|
|
||||||
|
const topDrag = useRulerPointerDrag({
|
||||||
|
rulerRef,
|
||||||
|
axis: "vertical",
|
||||||
|
disabled: !editable,
|
||||||
|
onDrag: (pagePx, clientX, clientY) => onMarginDrag?.("top", pagePx, clientX, clientY),
|
||||||
|
onDragEnd: () => onMarginDragEnd?.(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const bottomDrag = useRulerPointerDrag({
|
||||||
|
rulerRef,
|
||||||
|
axis: "vertical",
|
||||||
|
disabled: !editable,
|
||||||
|
onDrag: (pagePx, clientX, clientY) => onMarginDrag?.("bottom", pagePx, clientX, clientY),
|
||||||
|
onDragEnd: () => onMarginDragEnd?.(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapMarginDown =
|
||||||
|
(side: DocsRulerMarginSide, handler: (e: React.PointerEvent<HTMLDivElement>) => void) =>
|
||||||
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
onMarginDragStart?.(side)
|
||||||
|
handler(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rulerRef}
|
||||||
|
data-docs-ruler="vertical"
|
||||||
|
data-docs-ruler-scale={scale}
|
||||||
|
className="docs-vertical-ruler relative overflow-visible border-r border-[#dadce0] bg-white dark:border-border dark:bg-background"
|
||||||
|
style={{
|
||||||
|
width: DOCS_VERTICAL_RULER_WIDTH_PX,
|
||||||
|
height: scaledHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute left-0 right-0 bg-[#f1f3f4] dark:bg-muted/60"
|
||||||
|
style={{ top: 0, height: s(margins.top) }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute left-0 right-0 bg-[#f1f3f4] dark:bg-muted/60"
|
||||||
|
style={{
|
||||||
|
top: s(pageHeight - margins.bottom),
|
||||||
|
height: s(margins.bottom),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ticks.map((tick, index) => (
|
||||||
|
<div
|
||||||
|
key={`${tick.pos}-${index}`}
|
||||||
|
className="pointer-events-none absolute right-0 h-px bg-[#80868b] dark:bg-muted-foreground/70"
|
||||||
|
style={{
|
||||||
|
top: s(tick.pos),
|
||||||
|
width: tick.major ? 10 : 5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{ticks
|
||||||
|
.filter((tick) => tick.major && tick.label != null)
|
||||||
|
.map((tick) => (
|
||||||
|
<span
|
||||||
|
key={`vlabel-${tick.pos}`}
|
||||||
|
className="pointer-events-none absolute right-[11px] -translate-y-1/2 text-[9px] leading-none text-[#5f6368] dark:text-muted-foreground"
|
||||||
|
style={{ top: s(tick.pos) }}
|
||||||
|
>
|
||||||
|
{tick.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<DocsRulerDraggableHandle
|
||||||
|
style={{ top: s(margins.top) }}
|
||||||
|
axis="vertical"
|
||||||
|
disabled={!editable}
|
||||||
|
ariaLabel="Marge haute"
|
||||||
|
onPointerDown={wrapMarginDown("top", topDrag.onPointerDown)}
|
||||||
|
>
|
||||||
|
<DocsRulerUpTriangleMarker top={0} />
|
||||||
|
</DocsRulerDraggableHandle>
|
||||||
|
|
||||||
|
<DocsRulerDraggableHandle
|
||||||
|
style={{ top: s(pageHeight - margins.bottom) }}
|
||||||
|
axis="vertical"
|
||||||
|
disabled={!editable}
|
||||||
|
ariaLabel="Marge basse"
|
||||||
|
onPointerDown={wrapMarginDown("bottom", bottomDrag.onPointerDown)}
|
||||||
|
>
|
||||||
|
<DocsRulerDownTriangleMarker top={0} />
|
||||||
|
</DocsRulerDraggableHandle>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DocsVerticalRuler = memo(DocsVerticalRulerInner)
|
||||||
291
components/drive/richtext/docs-view-menu.tsx
Normal file
291
components/drive/richtext/docs-view-menu.tsx
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import {
|
||||||
|
AlignHorizontalSpaceAround,
|
||||||
|
Check,
|
||||||
|
Eye,
|
||||||
|
ListTree,
|
||||||
|
Maximize2,
|
||||||
|
MessageSquare,
|
||||||
|
MessageSquarePlus,
|
||||||
|
Pencil,
|
||||||
|
} from "lucide-react"
|
||||||
|
import {
|
||||||
|
MenubarCheckboxItem,
|
||||||
|
MenubarContent,
|
||||||
|
MenubarItem,
|
||||||
|
MenubarMenu,
|
||||||
|
MenubarSeparator,
|
||||||
|
MenubarSubContent,
|
||||||
|
MenubarSubTrigger,
|
||||||
|
MenubarTrigger,
|
||||||
|
} from "@/components/ui/menubar"
|
||||||
|
import {
|
||||||
|
DocsExclusiveMenuSub,
|
||||||
|
DocsExclusiveMenuSubRoot,
|
||||||
|
} from "@/components/drive/richtext/docs-exclusive-menu-sub"
|
||||||
|
import { DocsMenuShortcut } from "@/components/drive/richtext/docs-menu-shortcut"
|
||||||
|
import { DOCS_MENUBAR_CONTENT_PROPS } from "@/components/drive/richtext/docs-menubar-props"
|
||||||
|
import type {
|
||||||
|
DocsCommentsDisplay,
|
||||||
|
DocsEditorMode,
|
||||||
|
} from "@/lib/drive/docs-view-settings"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export type DocsViewMenuState = {
|
||||||
|
editorMode: DocsEditorMode
|
||||||
|
commentsDisplay: DocsCommentsDisplay
|
||||||
|
showLayout: boolean
|
||||||
|
showRuler: boolean
|
||||||
|
showEquationToolbar: boolean
|
||||||
|
showNonPrintableChars: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocsViewMenuActions = {
|
||||||
|
onEditorModeChange: (mode: DocsEditorMode) => void
|
||||||
|
onCommentsDisplayChange: (display: DocsCommentsDisplay) => void
|
||||||
|
onToggleOutlineSidebar: () => void
|
||||||
|
onToggleShowLayout: () => void
|
||||||
|
onToggleShowRuler: () => void
|
||||||
|
onToggleShowEquationToolbar: () => void
|
||||||
|
onToggleShowNonPrintableChars: () => void
|
||||||
|
onFullscreen: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDITOR_MODES = [
|
||||||
|
{
|
||||||
|
id: "edit" as const,
|
||||||
|
label: "Édition",
|
||||||
|
description: "Modifier le document directement",
|
||||||
|
icon: Pencil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "suggest" as const,
|
||||||
|
label: "Suggestion",
|
||||||
|
description: "Suggérer des modifications",
|
||||||
|
icon: MessageSquarePlus,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "view" as const,
|
||||||
|
label: "Affichage",
|
||||||
|
description: "Lire ou imprimer le document final",
|
||||||
|
icon: Eye,
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const COMMENTS_OPTIONS = [
|
||||||
|
{ id: "hidden" as const, label: "Masquer les commentaires" },
|
||||||
|
{ id: "collapsed" as const, label: "Réduire les commentaires" },
|
||||||
|
{ id: "expanded" as const, label: "Développer les commentaires" },
|
||||||
|
{ id: "all" as const, label: "Afficher tous les commentaires" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function MenuIcon({ children }: { children: ReactNode }) {
|
||||||
|
return <span className="docs-menu-item-icon">{children}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckMenuOption({
|
||||||
|
checked,
|
||||||
|
onSelect,
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
checked: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
children: ReactNode
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenubarItem
|
||||||
|
className="docs-menu-item relative pl-8"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
{checked ? <Check className="absolute left-2 size-4" aria-hidden /> : null}
|
||||||
|
{children}
|
||||||
|
</MenubarItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModeMenuOption({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
icon: typeof Pencil
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
selected: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenubarItem
|
||||||
|
className={cn("docs-menu-item docs-menu-mode-item items-start py-2", selected && "bg-accent/60")}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<MenuIcon>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
<span className="flex min-w-0 flex-col gap-0.5 leading-tight">
|
||||||
|
<span className="text-sm font-normal">{label}</span>
|
||||||
|
<span className="text-xs text-[#5f6368] dark:text-muted-foreground">{description}</span>
|
||||||
|
</span>
|
||||||
|
</MenubarItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocsViewMenu({
|
||||||
|
state,
|
||||||
|
actions,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
state: DocsViewMenuState
|
||||||
|
actions: DocsViewMenuActions
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenubarMenu>
|
||||||
|
<MenubarTrigger className="docs-menu-trigger">Affichage</MenubarTrigger>
|
||||||
|
<MenubarContent
|
||||||
|
{...DOCS_MENUBAR_CONTENT_PROPS}
|
||||||
|
className="docs-menu-content min-w-[300px] overflow-visible"
|
||||||
|
data-docs-menu-surface
|
||||||
|
>
|
||||||
|
<DocsExclusiveMenuSubRoot>
|
||||||
|
<DocsExclusiveMenuSub id="mode">
|
||||||
|
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
|
||||||
|
<MenuIcon>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Mode
|
||||||
|
</MenubarSubTrigger>
|
||||||
|
<MenubarSubContent
|
||||||
|
className="docs-menu-content docs-menu-sub-content min-w-[280px] overflow-visible"
|
||||||
|
data-docs-menu-surface
|
||||||
|
>
|
||||||
|
{EDITOR_MODES.map((mode) => (
|
||||||
|
<ModeMenuOption
|
||||||
|
key={mode.id}
|
||||||
|
icon={mode.icon}
|
||||||
|
label={mode.label}
|
||||||
|
description={mode.description}
|
||||||
|
selected={state.editorMode === mode.id}
|
||||||
|
disabled={disabled}
|
||||||
|
onSelect={() => actions.onEditorModeChange(mode.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</MenubarSubContent>
|
||||||
|
</DocsExclusiveMenuSub>
|
||||||
|
|
||||||
|
<DocsExclusiveMenuSub id="comments">
|
||||||
|
<MenubarSubTrigger className="docs-menu-item" disabled={disabled}>
|
||||||
|
<MenuIcon>
|
||||||
|
<MessageSquare className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Commentaires
|
||||||
|
</MenubarSubTrigger>
|
||||||
|
<MenubarSubContent
|
||||||
|
className="docs-menu-content docs-menu-sub-content min-w-[260px] overflow-visible"
|
||||||
|
data-docs-menu-surface
|
||||||
|
>
|
||||||
|
{COMMENTS_OPTIONS.map((option) => (
|
||||||
|
<CheckMenuOption
|
||||||
|
key={option.id}
|
||||||
|
checked={state.commentsDisplay === option.id}
|
||||||
|
disabled={disabled}
|
||||||
|
onSelect={() => actions.onCommentsDisplayChange(option.id)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</CheckMenuOption>
|
||||||
|
))}
|
||||||
|
</MenubarSubContent>
|
||||||
|
</DocsExclusiveMenuSub>
|
||||||
|
|
||||||
|
<MenubarItem
|
||||||
|
className="docs-menu-item"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={actions.onToggleOutlineSidebar}
|
||||||
|
>
|
||||||
|
<MenuIcon>
|
||||||
|
<ListTree className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Développer la barre latérale des onglets et sections
|
||||||
|
<span className="docs-menu-shortcut-sequence ml-auto text-xs text-[#5f6368] dark:text-muted-foreground">
|
||||||
|
Ctrl+⌥A Ctrl+⌥H
|
||||||
|
</span>
|
||||||
|
</MenubarItem>
|
||||||
|
|
||||||
|
<MenubarSeparator />
|
||||||
|
|
||||||
|
<DocsExclusiveMenuSub id="text-width">
|
||||||
|
<MenubarSubTrigger className="docs-menu-item" disabled>
|
||||||
|
<MenuIcon>
|
||||||
|
<AlignHorizontalSpaceAround className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Largeur du texte
|
||||||
|
</MenubarSubTrigger>
|
||||||
|
<MenubarSubContent
|
||||||
|
className="docs-menu-content docs-menu-sub-content min-w-[220px] overflow-visible"
|
||||||
|
data-docs-menu-surface
|
||||||
|
>
|
||||||
|
<MenubarItem disabled className="docs-menu-item text-muted-foreground">
|
||||||
|
Bientôt disponible
|
||||||
|
</MenubarItem>
|
||||||
|
</MenubarSubContent>
|
||||||
|
</DocsExclusiveMenuSub>
|
||||||
|
|
||||||
|
<MenubarSeparator />
|
||||||
|
</DocsExclusiveMenuSubRoot>
|
||||||
|
|
||||||
|
<MenubarCheckboxItem
|
||||||
|
className="docs-menu-item docs-menu-checkbox-item"
|
||||||
|
checked={state.showLayout}
|
||||||
|
disabled={disabled}
|
||||||
|
onCheckedChange={() => actions.onToggleShowLayout()}
|
||||||
|
>
|
||||||
|
Afficher la mise en page
|
||||||
|
</MenubarCheckboxItem>
|
||||||
|
<MenubarCheckboxItem
|
||||||
|
className="docs-menu-item docs-menu-checkbox-item"
|
||||||
|
checked={state.showRuler}
|
||||||
|
disabled={disabled}
|
||||||
|
onCheckedChange={() => actions.onToggleShowRuler()}
|
||||||
|
>
|
||||||
|
Afficher la règle
|
||||||
|
</MenubarCheckboxItem>
|
||||||
|
<MenubarCheckboxItem
|
||||||
|
className="docs-menu-item docs-menu-checkbox-item"
|
||||||
|
checked={state.showEquationToolbar}
|
||||||
|
disabled={disabled}
|
||||||
|
onCheckedChange={() => actions.onToggleShowEquationToolbar()}
|
||||||
|
>
|
||||||
|
Afficher la barre d'outils d'équation
|
||||||
|
</MenubarCheckboxItem>
|
||||||
|
<MenubarCheckboxItem
|
||||||
|
className="docs-menu-item docs-menu-checkbox-item"
|
||||||
|
checked={state.showNonPrintableChars}
|
||||||
|
disabled={disabled}
|
||||||
|
onCheckedChange={() => actions.onToggleShowNonPrintableChars()}
|
||||||
|
>
|
||||||
|
Afficher les caractères non imprimables
|
||||||
|
<DocsMenuShortcut shortcutId="view.showNonPrintable" />
|
||||||
|
</MenubarCheckboxItem>
|
||||||
|
|
||||||
|
<MenubarSeparator />
|
||||||
|
|
||||||
|
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onFullscreen}>
|
||||||
|
<MenuIcon>
|
||||||
|
<Maximize2 className="size-4" />
|
||||||
|
</MenuIcon>
|
||||||
|
Plein écran
|
||||||
|
</MenubarItem>
|
||||||
|
</MenubarContent>
|
||||||
|
</MenubarMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
155
components/drive/richtext/use-docs-ruler-margin-drag.ts
Normal file
155
components/drive/richtext/use-docs-ruler-margin-drag.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useRef, useState } from "react"
|
||||||
|
import type { DocPageLayout, DocPageSetup } from "@/lib/drive/doc-page-setup"
|
||||||
|
import { pxToMm } from "@/lib/drive/doc-page-setup"
|
||||||
|
import {
|
||||||
|
clampPageMarginPx,
|
||||||
|
mergeMarginPreview,
|
||||||
|
type DocsPageMarginsPx,
|
||||||
|
type DocsRulerMarginSide,
|
||||||
|
} from "@/lib/drive/docs-ruler-margin-math"
|
||||||
|
import { formatMarginDistanceLabel } from "@/lib/drive/docs-ruler-units"
|
||||||
|
import type { DocsRulerDragTooltipState } from "@/components/drive/richtext/docs-ruler-markers"
|
||||||
|
|
||||||
|
function applyMarginDragPx(
|
||||||
|
side: DocsRulerMarginSide,
|
||||||
|
rawValuePx: number,
|
||||||
|
base: DocsPageMarginsPx,
|
||||||
|
preview: Partial<DocsPageMarginsPx> | null,
|
||||||
|
pageWidth: number,
|
||||||
|
pageHeight: number
|
||||||
|
): { nextPreview: Partial<DocsPageMarginsPx>; clamped: number } {
|
||||||
|
const current = mergeMarginPreview(base, preview)
|
||||||
|
let nextValue = rawValuePx
|
||||||
|
if (side === "right") {
|
||||||
|
nextValue = pageWidth - rawValuePx
|
||||||
|
} else if (side === "bottom") {
|
||||||
|
nextValue = pageHeight - rawValuePx
|
||||||
|
}
|
||||||
|
const clamped = clampPageMarginPx(
|
||||||
|
side,
|
||||||
|
nextValue,
|
||||||
|
current,
|
||||||
|
pageWidth,
|
||||||
|
pageHeight
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
nextPreview: { ...(preview ?? {}), [side]: clamped },
|
||||||
|
clamped,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDocsRulerMarginDrag({
|
||||||
|
pageLayout,
|
||||||
|
editable,
|
||||||
|
onPageSetupChange,
|
||||||
|
}: {
|
||||||
|
pageLayout: DocPageLayout
|
||||||
|
editable: boolean
|
||||||
|
onPageSetupChange?: (
|
||||||
|
patch: Partial<DocPageSetup>,
|
||||||
|
options?: { immediate?: boolean }
|
||||||
|
) => void
|
||||||
|
}) {
|
||||||
|
const [previewPx, setPreviewPx] = useState<Partial<DocsPageMarginsPx> | null>(null)
|
||||||
|
const [dragTooltip, setDragTooltip] = useState<DocsRulerDragTooltipState>(null)
|
||||||
|
const dragBaseRef = useRef<DocsPageMarginsPx>(pageLayout.marginsPx)
|
||||||
|
/** Sync preview during drag — window pointer events don't flush React state in time. */
|
||||||
|
const previewRef = useRef<Partial<DocsPageMarginsPx> | null>(null)
|
||||||
|
const pageLayoutRef = useRef(pageLayout)
|
||||||
|
pageLayoutRef.current = pageLayout
|
||||||
|
|
||||||
|
const marginsPx = useMemo(
|
||||||
|
() => mergeMarginPreview(pageLayout.marginsPx, previewPx),
|
||||||
|
[pageLayout.marginsPx, previewPx]
|
||||||
|
)
|
||||||
|
|
||||||
|
const pageLayoutWithMargins = useMemo(
|
||||||
|
(): DocPageLayout => ({
|
||||||
|
...pageLayout,
|
||||||
|
marginsPx,
|
||||||
|
}),
|
||||||
|
[pageLayout, marginsPx]
|
||||||
|
)
|
||||||
|
|
||||||
|
const beginMarginDrag = useCallback(
|
||||||
|
(_side: DocsRulerMarginSide) => {
|
||||||
|
if (!editable) return
|
||||||
|
const layout = pageLayoutRef.current
|
||||||
|
dragBaseRef.current = mergeMarginPreview(layout.marginsPx, previewRef.current)
|
||||||
|
previewRef.current = null
|
||||||
|
},
|
||||||
|
[editable]
|
||||||
|
)
|
||||||
|
|
||||||
|
const moveMarginDrag = useCallback(
|
||||||
|
(side: DocsRulerMarginSide, rawValuePx: number, clientX: number, clientY: number) => {
|
||||||
|
if (!editable) return
|
||||||
|
|
||||||
|
const layout = pageLayoutRef.current
|
||||||
|
const { nextPreview, clamped } = applyMarginDragPx(
|
||||||
|
side,
|
||||||
|
rawValuePx,
|
||||||
|
dragBaseRef.current,
|
||||||
|
previewRef.current,
|
||||||
|
layout.widthPx,
|
||||||
|
layout.heightPx
|
||||||
|
)
|
||||||
|
|
||||||
|
previewRef.current = nextPreview
|
||||||
|
setPreviewPx(nextPreview)
|
||||||
|
setDragTooltip({
|
||||||
|
label: formatMarginDistanceLabel(clamped, layout.format.id),
|
||||||
|
x: clientX,
|
||||||
|
y: clientY,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[editable]
|
||||||
|
)
|
||||||
|
|
||||||
|
const endMarginDrag = useCallback(() => {
|
||||||
|
setDragTooltip(null)
|
||||||
|
|
||||||
|
const preview = previewRef.current
|
||||||
|
previewRef.current = null
|
||||||
|
|
||||||
|
if (!editable || !preview) {
|
||||||
|
setPreviewPx(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout = pageLayoutRef.current
|
||||||
|
const merged = mergeMarginPreview(layout.marginsPx, preview)
|
||||||
|
setPreviewPx(null)
|
||||||
|
|
||||||
|
onPageSetupChange?.(
|
||||||
|
{
|
||||||
|
marginsMm: {
|
||||||
|
top: pxToMm(merged.top),
|
||||||
|
right: pxToMm(merged.right),
|
||||||
|
bottom: pxToMm(merged.bottom),
|
||||||
|
left: pxToMm(merged.left),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
}, [editable, onPageSetupChange])
|
||||||
|
|
||||||
|
const cancelMarginDrag = useCallback(() => {
|
||||||
|
previewRef.current = null
|
||||||
|
setPreviewPx(null)
|
||||||
|
setDragTooltip(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
marginsPx,
|
||||||
|
pageLayoutWithMargins,
|
||||||
|
marginDragActive: previewPx != null,
|
||||||
|
dragTooltip,
|
||||||
|
beginMarginDrag,
|
||||||
|
moveMarginDrag,
|
||||||
|
endMarginDrag,
|
||||||
|
cancelMarginDrag,
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -27,17 +27,24 @@ function SelectValue({
|
|||||||
function SelectTrigger({
|
function SelectTrigger({
|
||||||
className,
|
className,
|
||||||
size = 'default',
|
size = 'default',
|
||||||
|
variant = 'default',
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
size?: 'sm' | 'default'
|
size?: 'sm' | 'default'
|
||||||
|
variant?: 'default' | 'ghost'
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md bg-transparent text-sm whitespace-nowrap transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
variant === 'default' &&
|
||||||
|
"border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 border px-3 py-2 shadow-xs focus-visible:ring-[3px]",
|
||||||
|
variant === 'ghost' &&
|
||||||
|
"border-0 shadow-none hover:bg-transparent focus-visible:ring-0 dark:bg-transparent dark:hover:bg-transparent dark:data-[state=open]:bg-transparent",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -38,15 +38,39 @@ export type DocPageBorders = {
|
|||||||
offsetFrom?: "page" | "text"
|
offsetFrom?: "page" | "text"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DocPageHeaderFooter = {
|
||||||
|
content: Record<string, unknown>
|
||||||
|
heightMm?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocPageNumberSettings = {
|
||||||
|
enabled: boolean
|
||||||
|
placement: "header" | "footer"
|
||||||
|
align: "left" | "center" | "right"
|
||||||
|
startAt: number
|
||||||
|
showOnFirstPage: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type DocPageSetup = {
|
export type DocPageSetup = {
|
||||||
widthMm: number
|
widthMm: number
|
||||||
heightMm: number
|
heightMm: number
|
||||||
marginsMm: DocPageMarginsMm
|
marginsMm: DocPageMarginsMm
|
||||||
|
/** Distance from page top to header content (cm/mm from top). */
|
||||||
|
headerMarginMm?: number
|
||||||
|
/** Distance from page bottom to footer content. */
|
||||||
|
footerMarginMm?: number
|
||||||
formatId?: PageFormatId | null
|
formatId?: PageFormatId | null
|
||||||
orientation?: "portrait" | "landscape"
|
orientation?: "portrait" | "landscape"
|
||||||
pageColor?: string | null
|
pageColor?: string | null
|
||||||
pageBackground?: DocPageBackground | null
|
pageBackground?: DocPageBackground | null
|
||||||
borders?: DocPageBorders | null
|
borders?: DocPageBorders | null
|
||||||
|
header?: DocPageHeaderFooter | null
|
||||||
|
footer?: DocPageHeaderFooter | null
|
||||||
|
headerFirstPage?: DocPageHeaderFooter | null
|
||||||
|
footerFirstPage?: DocPageHeaderFooter | null
|
||||||
|
headerFooterDifferentFirstPage?: boolean
|
||||||
|
headerFooterDifferentOddEven?: boolean
|
||||||
|
pageNumbers?: DocPageNumberSettings | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DocPageBorderCss = {
|
export type DocPageBorderCss = {
|
||||||
@ -65,6 +89,15 @@ export type DocPageLayout = {
|
|||||||
pageBackgroundLayers?: DocPageBackgroundLayers
|
pageBackgroundLayers?: DocPageBackgroundLayers
|
||||||
sheetBorderCss?: DocPageBorderCss
|
sheetBorderCss?: DocPageBorderCss
|
||||||
textAreaBorderCss?: DocPageBorderCss
|
textAreaBorderCss?: DocPageBorderCss
|
||||||
|
header?: DocPageHeaderFooter | null
|
||||||
|
footer?: DocPageHeaderFooter | null
|
||||||
|
headerFirstPage?: DocPageHeaderFooter | null
|
||||||
|
footerFirstPage?: DocPageHeaderFooter | null
|
||||||
|
headerMarginMm?: number
|
||||||
|
footerMarginMm?: number
|
||||||
|
headerFooterDifferentFirstPage?: boolean
|
||||||
|
headerFooterDifferentOddEven?: boolean
|
||||||
|
pageNumbers?: DocPageNumberSettings | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function twipsToMm(twips: number): number {
|
export function twipsToMm(twips: number): number {
|
||||||
@ -75,6 +108,10 @@ export function mmToPx(mm: number): number {
|
|||||||
return Math.round(mm * MM_TO_PX)
|
return Math.round(mm * MM_TO_PX)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function pxToMm(px: number): number {
|
||||||
|
return Math.round((px / MM_TO_PX) * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
export function defaultPageMarginsMm(): DocPageMarginsMm {
|
export function defaultPageMarginsMm(): DocPageMarginsMm {
|
||||||
const cm = 2
|
const cm = 2
|
||||||
return { top: cm * 10, right: cm * 10, bottom: cm * 10, left: cm * 10 }
|
return { top: cm * 10, right: cm * 10, bottom: cm * 10, left: cm * 10 }
|
||||||
@ -152,6 +189,15 @@ export function resolveDocumentPageLayout(
|
|||||||
},
|
},
|
||||||
pageColor: normalizePageColor(pageSetup.pageColor),
|
pageColor: normalizePageColor(pageSetup.pageColor),
|
||||||
pageBackgroundLayers: resolvePageBackgroundLayers(pageSetup.pageBackground),
|
pageBackgroundLayers: resolvePageBackgroundLayers(pageSetup.pageBackground),
|
||||||
|
header: pageSetup.header ?? null,
|
||||||
|
footer: pageSetup.footer ?? null,
|
||||||
|
headerFirstPage: pageSetup.headerFirstPage ?? null,
|
||||||
|
footerFirstPage: pageSetup.footerFirstPage ?? null,
|
||||||
|
headerMarginMm: pageSetup.headerMarginMm,
|
||||||
|
footerMarginMm: pageSetup.footerMarginMm,
|
||||||
|
headerFooterDifferentFirstPage: pageSetup.headerFooterDifferentFirstPage ?? false,
|
||||||
|
headerFooterDifferentOddEven: pageSetup.headerFooterDifferentOddEven ?? false,
|
||||||
|
pageNumbers: pageSetup.pageNumbers ?? null,
|
||||||
...borderLayers,
|
...borderLayers,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -308,6 +354,13 @@ export function buildPageSetupFromDraft(
|
|||||||
pageColor: pageColor === "#ffffff" ? null : pageColor,
|
pageColor: pageColor === "#ffffff" ? null : pageColor,
|
||||||
pageBackground: previous?.pageBackground ?? null,
|
pageBackground: previous?.pageBackground ?? null,
|
||||||
borders: previous?.borders ?? null,
|
borders: previous?.borders ?? null,
|
||||||
|
header: previous?.header ?? null,
|
||||||
|
footer: previous?.footer ?? null,
|
||||||
|
headerMarginMm: previous?.headerMarginMm,
|
||||||
|
footerMarginMm: previous?.footerMarginMm,
|
||||||
|
headerFooterDifferentFirstPage: previous?.headerFooterDifferentFirstPage ?? false,
|
||||||
|
headerFooterDifferentOddEven: previous?.headerFooterDifferentOddEven ?? false,
|
||||||
|
pageNumbers: previous?.pageNumbers ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -345,6 +398,8 @@ function parseSectPr(sectionXml: string): Omit<DocPageSetup, "pageColor" | "page
|
|||||||
const right = readTwipsAttr(pgMarMatch[0], "right")
|
const right = readTwipsAttr(pgMarMatch[0], "right")
|
||||||
const bottom = readTwipsAttr(pgMarMatch[0], "bottom")
|
const bottom = readTwipsAttr(pgMarMatch[0], "bottom")
|
||||||
const left = readTwipsAttr(pgMarMatch[0], "left")
|
const left = readTwipsAttr(pgMarMatch[0], "left")
|
||||||
|
const header = readTwipsAttr(pgMarMatch[0], "header")
|
||||||
|
const footer = readTwipsAttr(pgMarMatch[0], "footer")
|
||||||
if (top == null || right == null || bottom == null || left == null) return null
|
if (top == null || right == null || bottom == null || left == null) return null
|
||||||
|
|
||||||
const marginsMm = {
|
const marginsMm = {
|
||||||
@ -358,6 +413,8 @@ function parseSectPr(sectionXml: string): Omit<DocPageSetup, "pageColor" | "page
|
|||||||
widthMm,
|
widthMm,
|
||||||
heightMm,
|
heightMm,
|
||||||
marginsMm,
|
marginsMm,
|
||||||
|
headerMarginMm: header != null ? twipsToMm(header) : marginsMm.top,
|
||||||
|
footerMarginMm: footer != null ? twipsToMm(footer) : marginsMm.bottom,
|
||||||
formatId: matchPageFormatId(widthMm, heightMm),
|
formatId: matchPageFormatId(widthMm, heightMm),
|
||||||
orientation: orient === "landscape" ? "landscape" : "portrait",
|
orientation: orient === "landscape" ? "landscape" : "portrait",
|
||||||
borders: parsePgBorders(sectionXml),
|
borders: parsePgBorders(sectionXml),
|
||||||
|
|||||||
89
lib/drive/docs-graphic-assets.ts
Normal file
89
lib/drive/docs-graphic-assets.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import type { TipTapJSON } from "@/lib/drive/richtext-import"
|
||||||
|
|
||||||
|
const BASE64_PREFIX = "data:"
|
||||||
|
|
||||||
|
/** Collect base64 image src values from TipTap JSON. */
|
||||||
|
export function collectBase64ImageSrcs(content: TipTapJSON): string[] {
|
||||||
|
const srcs: string[] = []
|
||||||
|
const walk = (node: unknown) => {
|
||||||
|
if (!node || typeof node !== "object") return
|
||||||
|
const record = node as TipTapJSON
|
||||||
|
const attrs = record.attrs as Record<string, unknown> | undefined
|
||||||
|
if (
|
||||||
|
(record.type === "image" ||
|
||||||
|
record.type === "docsGraphic" ||
|
||||||
|
record.type === "docsInlineGraphic") &&
|
||||||
|
typeof attrs?.src === "string" &&
|
||||||
|
attrs.src.startsWith(BASE64_PREFIX)
|
||||||
|
) {
|
||||||
|
srcs.push(attrs.src)
|
||||||
|
}
|
||||||
|
if (Array.isArray(record.content)) record.content.forEach(walk)
|
||||||
|
}
|
||||||
|
walk(content)
|
||||||
|
return srcs
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UploadedAsset = {
|
||||||
|
assetId: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload a base64 image and return asset reference. Phase 2 blob migration. */
|
||||||
|
export async function uploadGraphicAsset(
|
||||||
|
src: string,
|
||||||
|
uploadFn: (body: { dataUrl: string }) => Promise<UploadedAsset>
|
||||||
|
): Promise<UploadedAsset | null> {
|
||||||
|
if (!src.startsWith(BASE64_PREFIX)) return null
|
||||||
|
try {
|
||||||
|
return await uploadFn({ dataUrl: src })
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace base64 src with asset URL in TipTap JSON (lazy migration). */
|
||||||
|
export function applyAssetToContent(
|
||||||
|
content: TipTapJSON,
|
||||||
|
src: string,
|
||||||
|
asset: UploadedAsset
|
||||||
|
): TipTapJSON {
|
||||||
|
const walk = (node: unknown): unknown => {
|
||||||
|
if (!node || typeof node !== "object") return node
|
||||||
|
if (Array.isArray(node)) return node.map(walk)
|
||||||
|
const record = node as TipTapJSON
|
||||||
|
const attrs = record.attrs as Record<string, unknown> | undefined
|
||||||
|
if (
|
||||||
|
attrs &&
|
||||||
|
attrs.src === src &&
|
||||||
|
(record.type === "image" ||
|
||||||
|
record.type === "docsGraphic" ||
|
||||||
|
record.type === "docsInlineGraphic")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
attrs: { ...attrs, src: asset.url, assetId: asset.assetId },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(record.content)) {
|
||||||
|
return { ...record, content: record.content.map(walk) }
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
const next = walk(content)
|
||||||
|
return (next && typeof next === "object" ? next : content) as TipTapJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Migrate all base64 images in document to backend assets when uploadFn provided. */
|
||||||
|
export async function migrateBase64ImagesInContent(
|
||||||
|
content: TipTapJSON,
|
||||||
|
uploadFn: (body: { dataUrl: string }) => Promise<UploadedAsset>
|
||||||
|
): Promise<TipTapJSON> {
|
||||||
|
let result = content
|
||||||
|
const srcs = [...new Set(collectBase64ImageSrcs(content))]
|
||||||
|
for (const src of srcs) {
|
||||||
|
const asset = await uploadGraphicAsset(src, uploadFn)
|
||||||
|
if (asset) result = applyAssetToContent(result, src, asset)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
206
lib/drive/docs-graphic-import.ts
Normal file
206
lib/drive/docs-graphic-import.ts
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import type { TipTapJSON } from "@/lib/drive/richtext-import"
|
||||||
|
import {
|
||||||
|
DOCS_GRAPHIC_DEFAULTS,
|
||||||
|
imageAttrsToGraphic,
|
||||||
|
parseGraphicAttrs,
|
||||||
|
type DocsGraphicWrap,
|
||||||
|
type DocsGraphicPlacement,
|
||||||
|
} from "./docs-graphic-types.ts"
|
||||||
|
|
||||||
|
const LEGACY_IMAGE_KEYS = [
|
||||||
|
"src",
|
||||||
|
"alt",
|
||||||
|
"title",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"placement",
|
||||||
|
"wrap",
|
||||||
|
"floatSide",
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"rotationDeg",
|
||||||
|
"zIndex",
|
||||||
|
"cropX",
|
||||||
|
"cropY",
|
||||||
|
"cropWidth",
|
||||||
|
"cropHeight",
|
||||||
|
"cropShape",
|
||||||
|
"assetId",
|
||||||
|
"opacity",
|
||||||
|
"shadow",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const DOCX_WRAP_MAP: Record<string, DocsGraphicWrap> = {
|
||||||
|
inline: "inline",
|
||||||
|
square: "square",
|
||||||
|
tight: "tight",
|
||||||
|
through: "through",
|
||||||
|
topAndBottom: "top-bottom",
|
||||||
|
topbottom: "top-bottom",
|
||||||
|
behind: "behind",
|
||||||
|
infront: "in-front",
|
||||||
|
inFront: "in-front",
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOCX_PLACEMENT_MAP: Record<string, DocsGraphicPlacement> = {
|
||||||
|
inline: "inline",
|
||||||
|
block: "block",
|
||||||
|
absolute: "absolute",
|
||||||
|
anchored: "absolute",
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraphIsSingleImage(node: TipTapJSON): boolean {
|
||||||
|
if (node.type !== "paragraph" || !Array.isArray(node.content) || node.content.length !== 1) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const child = node.content[0] as TipTapJSON
|
||||||
|
return child.type === "image" || child.type === "docsInlineGraphic"
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgradeImageNode(raw: TipTapJSON, asBlock: boolean): TipTapJSON {
|
||||||
|
const attrs = isRecord(raw.attrs) ? raw.attrs : {}
|
||||||
|
const graphic = imageAttrsToGraphic(attrs)
|
||||||
|
if (asBlock) {
|
||||||
|
return {
|
||||||
|
type: "docsGraphic",
|
||||||
|
attrs: {
|
||||||
|
...graphic,
|
||||||
|
placement: graphic.placement === "inline" ? "block" : graphic.placement,
|
||||||
|
wrap: graphic.wrap === "inline" ? "square" : graphic.wrap,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "docsInlineGraphic",
|
||||||
|
attrs: {
|
||||||
|
...graphic,
|
||||||
|
placement: "inline",
|
||||||
|
wrap: graphic.wrap === "square" ? "inline" : graphic.wrap,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgradeGraphicNode(raw: TipTapJSON): TipTapJSON {
|
||||||
|
const attrs = isRecord(raw.attrs) ? parseGraphicAttrs(raw.attrs) : DOCS_GRAPHIC_DEFAULTS
|
||||||
|
if (raw.type === "docsGraphic") {
|
||||||
|
return { type: "docsGraphic", attrs }
|
||||||
|
}
|
||||||
|
if (raw.type === "docsInlineGraphic") {
|
||||||
|
return { type: "docsInlineGraphic", attrs }
|
||||||
|
}
|
||||||
|
if (raw.type === "image") {
|
||||||
|
return upgradeImageNode(raw, false)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapImportedGraphicAttrs(attrs: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const wrapRaw = attrs.wrap ?? attrs.textWrap ?? attrs.layout
|
||||||
|
const placementRaw = attrs.placement ?? attrs.position ?? attrs.layoutMode
|
||||||
|
const wrap =
|
||||||
|
typeof wrapRaw === "string" ? (DOCX_WRAP_MAP[wrapRaw] ?? wrapRaw) : undefined
|
||||||
|
const placement =
|
||||||
|
typeof placementRaw === "string"
|
||||||
|
? (DOCX_PLACEMENT_MAP[placementRaw] ?? placementRaw)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
...attrs,
|
||||||
|
...(wrap ? { wrap } : {}),
|
||||||
|
...(placement ? { placement } : {}),
|
||||||
|
floatSide: attrs.floatSide ?? attrs.align ?? attrs.horizontalAlign,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upgrade legacy `image` nodes and normalize graphic attrs after DOCX import. */
|
||||||
|
export function normalizeImportedGraphics(content: TipTapJSON): TipTapJSON {
|
||||||
|
const walk = (node: unknown): unknown => {
|
||||||
|
if (!node || typeof node !== "object") return node
|
||||||
|
if (Array.isArray(node)) return node.map(walk)
|
||||||
|
|
||||||
|
const record = node as TipTapJSON
|
||||||
|
if (record.type === "image" && isRecord(record.attrs)) {
|
||||||
|
const mapped = mapImportedGraphicAttrs(record.attrs)
|
||||||
|
const placement = mapped.placement as string | undefined
|
||||||
|
const wrap = mapped.wrap as string | undefined
|
||||||
|
const asBlock =
|
||||||
|
wrap === "square" ||
|
||||||
|
wrap === "tight" ||
|
||||||
|
wrap === "through" ||
|
||||||
|
wrap === "top-bottom" ||
|
||||||
|
wrap === "behind" ||
|
||||||
|
wrap === "in-front" ||
|
||||||
|
placement === "block" ||
|
||||||
|
placement === "absolute"
|
||||||
|
return upgradeImageNode({ ...record, attrs: mapped }, asBlock)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(record.type === "docsGraphic" || record.type === "docsInlineGraphic") &&
|
||||||
|
isRecord(record.attrs)
|
||||||
|
) {
|
||||||
|
record.attrs = parseGraphicAttrs(mapImportedGraphicAttrs(record.attrs))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.type === "paragraph" && paragraphIsSingleImage(record)) {
|
||||||
|
const child = record.content![0] as TipTapJSON
|
||||||
|
if (child.type === "image") {
|
||||||
|
const upgraded = upgradeImageNode(
|
||||||
|
{ ...child, attrs: mapImportedGraphicAttrs((child.attrs as Record<string, unknown>) ?? {}) },
|
||||||
|
true
|
||||||
|
)
|
||||||
|
return upgraded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(record.content)) {
|
||||||
|
const nextContent = record.content.map(walk).filter(Boolean)
|
||||||
|
if (record.type === "paragraph") {
|
||||||
|
return { ...record, content: nextContent }
|
||||||
|
}
|
||||||
|
return { ...record, content: nextContent }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.type === "image" || record.type === "docsGraphic" || record.type === "docsInlineGraphic") {
|
||||||
|
return upgradeGraphicNode(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = walk(content)
|
||||||
|
if (!normalized || typeof normalized !== "object") {
|
||||||
|
return { type: "doc", content: [{ type: "paragraph" }] }
|
||||||
|
}
|
||||||
|
return normalized as TipTapJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep legacy inline images compatible with @tiptap/extension-image when needed. */
|
||||||
|
export function normalizeLegacyImageAttrs(content: TipTapJSON): TipTapJSON {
|
||||||
|
const walk = (node: unknown): unknown => {
|
||||||
|
if (!node || typeof node !== "object") return node
|
||||||
|
if (Array.isArray(node)) return node.map(walk)
|
||||||
|
const record = node as TipTapJSON
|
||||||
|
if (record.type === "image" && isRecord(record.attrs)) {
|
||||||
|
const attrs: Record<string, unknown> = {}
|
||||||
|
for (const key of LEGACY_IMAGE_KEYS) {
|
||||||
|
const value = record.attrs[key]
|
||||||
|
if (value != null && value !== "") attrs[key] = value
|
||||||
|
}
|
||||||
|
if (typeof attrs.src !== "string" || !attrs.src) return null
|
||||||
|
return { ...record, attrs }
|
||||||
|
}
|
||||||
|
if (Array.isArray(record.content)) {
|
||||||
|
return { ...record, content: record.content.map(walk).filter(Boolean) }
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
const normalized = walk(content)
|
||||||
|
if (!normalized || typeof normalized !== "object") {
|
||||||
|
return { type: "doc", content: [{ type: "paragraph" }] }
|
||||||
|
}
|
||||||
|
return normalized as TipTapJSON
|
||||||
|
}
|
||||||
151
lib/drive/docs-graphic-layout.ts
Normal file
151
lib/drive/docs-graphic-layout.ts
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import type { CSSProperties } from "react"
|
||||||
|
import {
|
||||||
|
type DocsGraphicAttrs,
|
||||||
|
parseGraphicAttrs,
|
||||||
|
} from "./docs-graphic-types.ts"
|
||||||
|
|
||||||
|
export type DocsGraphicLayoutStyle = {
|
||||||
|
wrapper: CSSProperties
|
||||||
|
inner: CSSProperties
|
||||||
|
content: CSSProperties
|
||||||
|
behindText: boolean
|
||||||
|
inFrontText: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeGraphicLayoutStyle(
|
||||||
|
raw: Record<string, unknown> | DocsGraphicAttrs
|
||||||
|
): DocsGraphicLayoutStyle {
|
||||||
|
const attrs = "graphicType" in raw && typeof raw.graphicType === "string"
|
||||||
|
? (raw as DocsGraphicAttrs)
|
||||||
|
: parseGraphicAttrs(raw)
|
||||||
|
|
||||||
|
const width = attrs.width
|
||||||
|
const height = attrs.height
|
||||||
|
const rotation = attrs.rotationDeg
|
||||||
|
const transformParts = rotation ? [`rotate(${rotation}deg)`] : []
|
||||||
|
|
||||||
|
const behindText = attrs.wrap === "behind"
|
||||||
|
const inFrontText = attrs.wrap === "in-front"
|
||||||
|
const isAbsolute = attrs.placement === "absolute" || behindText || inFrontText
|
||||||
|
|
||||||
|
const wrapper: CSSProperties = {
|
||||||
|
width: isAbsolute ? undefined : width,
|
||||||
|
height: isAbsolute ? undefined : height,
|
||||||
|
maxWidth: "100%",
|
||||||
|
}
|
||||||
|
|
||||||
|
const inner: CSSProperties = {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
transform: transformParts.length ? transformParts.join(" ") : undefined,
|
||||||
|
transformOrigin: "center center",
|
||||||
|
position: isAbsolute ? "absolute" : "relative",
|
||||||
|
left: isAbsolute ? attrs.x : undefined,
|
||||||
|
top: isAbsolute ? attrs.y : undefined,
|
||||||
|
zIndex: behindText ? 0 : inFrontText ? 20 : attrs.zIndex || undefined,
|
||||||
|
pointerEvents: behindText ? "none" : "auto",
|
||||||
|
opacity: attrs.opacity < 1 ? attrs.opacity : undefined,
|
||||||
|
boxShadow: attrs.shadow || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.placement === "inline" || attrs.wrap === "inline") {
|
||||||
|
inner.display = "inline-block"
|
||||||
|
inner.verticalAlign = "baseline"
|
||||||
|
} else if (attrs.wrap === "top-bottom") {
|
||||||
|
inner.display = "block"
|
||||||
|
inner.clear = "both"
|
||||||
|
inner.marginBlock = "8px"
|
||||||
|
if (attrs.floatSide === "center") {
|
||||||
|
inner.marginInline = "auto"
|
||||||
|
}
|
||||||
|
} else if (attrs.wrap === "square" || attrs.wrap === "tight" || attrs.wrap === "through") {
|
||||||
|
inner.display = "block"
|
||||||
|
inner.float = attrs.floatSide === "right" ? "right" : "left"
|
||||||
|
inner.marginInlineStart = attrs.floatSide === "right" ? "12px" : undefined
|
||||||
|
inner.marginInlineEnd = attrs.floatSide === "right" ? undefined : "12px"
|
||||||
|
inner.marginBlock = "4px"
|
||||||
|
if (attrs.wrap === "tight") {
|
||||||
|
inner.shapeOutside = "margin-box"
|
||||||
|
inner.marginInlineStart = attrs.floatSide === "right" ? "6px" : undefined
|
||||||
|
inner.marginInlineEnd = attrs.floatSide === "right" ? undefined : "6px"
|
||||||
|
}
|
||||||
|
if (attrs.wrap === "through") {
|
||||||
|
inner.opacity = 0.85
|
||||||
|
inner.mixBlendMode = "multiply"
|
||||||
|
}
|
||||||
|
} else if (attrs.placement === "block") {
|
||||||
|
inner.display = "block"
|
||||||
|
inner.marginBlock = "8px"
|
||||||
|
if (attrs.floatSide === "center") {
|
||||||
|
inner.marginInline = "auto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const content: CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
}
|
||||||
|
|
||||||
|
return { wrapper, inner, content, behindText, inFrontText }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RESIZE_HANDLES = [
|
||||||
|
"nw",
|
||||||
|
"n",
|
||||||
|
"ne",
|
||||||
|
"e",
|
||||||
|
"se",
|
||||||
|
"s",
|
||||||
|
"sw",
|
||||||
|
"w",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type ResizeHandle = (typeof RESIZE_HANDLES)[number]
|
||||||
|
|
||||||
|
export function resizeWithHandle(
|
||||||
|
handle: ResizeHandle,
|
||||||
|
startWidth: number,
|
||||||
|
startHeight: number,
|
||||||
|
dx: number,
|
||||||
|
dy: number,
|
||||||
|
minSize = 24,
|
||||||
|
lockAspect = false
|
||||||
|
): { width: number; height: number; xOffset: number; yOffset: number } {
|
||||||
|
let width = startWidth
|
||||||
|
let height = startHeight
|
||||||
|
let xOffset = 0
|
||||||
|
let yOffset = 0
|
||||||
|
|
||||||
|
if (lockAspect) {
|
||||||
|
const aspect = startWidth / Math.max(startHeight, 1)
|
||||||
|
if (Math.abs(dx) >= Math.abs(dy)) {
|
||||||
|
width = startWidth + (handle.includes("w") ? -dx : handle.includes("e") ? dx : dx)
|
||||||
|
height = width / aspect
|
||||||
|
} else {
|
||||||
|
height = startHeight + (handle.includes("n") ? -dy : handle.includes("s") ? dy : dy)
|
||||||
|
width = height * aspect
|
||||||
|
}
|
||||||
|
if (handle.includes("w")) xOffset = startWidth - width
|
||||||
|
if (handle.includes("n")) yOffset = startHeight - height
|
||||||
|
} else {
|
||||||
|
if (handle.includes("e")) width = startWidth + dx
|
||||||
|
if (handle.includes("w")) {
|
||||||
|
width = startWidth - dx
|
||||||
|
xOffset = dx
|
||||||
|
}
|
||||||
|
if (handle.includes("s")) height = startHeight + dy
|
||||||
|
if (handle.includes("n")) {
|
||||||
|
height = startHeight - dy
|
||||||
|
yOffset = dy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
width = Math.max(minSize, width)
|
||||||
|
height = Math.max(minSize, height)
|
||||||
|
|
||||||
|
if (handle.includes("w") && width === minSize) xOffset = startWidth - minSize
|
||||||
|
if (handle.includes("n") && height === minSize) yOffset = startHeight - minSize
|
||||||
|
|
||||||
|
return { width: Math.round(width), height: Math.round(height), xOffset, yOffset }
|
||||||
|
}
|
||||||
256
lib/drive/docs-graphic-types.ts
Normal file
256
lib/drive/docs-graphic-types.ts
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
export type DocsGraphicType = "image" | "shape" | "gradient"
|
||||||
|
|
||||||
|
export type DocsGraphicPlacement = "inline" | "block" | "absolute"
|
||||||
|
|
||||||
|
/** Text wrap / layering relative to body text */
|
||||||
|
export type DocsGraphicWrap =
|
||||||
|
| "inline"
|
||||||
|
| "square"
|
||||||
|
| "tight"
|
||||||
|
| "through"
|
||||||
|
| "top-bottom"
|
||||||
|
| "behind"
|
||||||
|
| "in-front"
|
||||||
|
|
||||||
|
export type DocsGraphicFloatSide = "left" | "right" | "center"
|
||||||
|
|
||||||
|
export type DocsShapeType = "rect" | "ellipse" | "line" | "arrow"
|
||||||
|
|
||||||
|
export type DocsCropShape = "rect" | "ellipse"
|
||||||
|
|
||||||
|
export type DocsGraphicAttrs = {
|
||||||
|
graphicType: DocsGraphicType
|
||||||
|
src: string | null
|
||||||
|
alt: string
|
||||||
|
assetId: string | null
|
||||||
|
shapeType: DocsShapeType
|
||||||
|
fill: string
|
||||||
|
stroke: string
|
||||||
|
strokeWidth: number
|
||||||
|
gradientCss: string
|
||||||
|
gradientAngle: number
|
||||||
|
gradientColor1: string
|
||||||
|
gradientColor2: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
placement: DocsGraphicPlacement
|
||||||
|
wrap: DocsGraphicWrap
|
||||||
|
floatSide: DocsGraphicFloatSide
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
rotationDeg: number
|
||||||
|
zIndex: number
|
||||||
|
/** Normalized crop region 0–1 relative to source image */
|
||||||
|
cropX: number
|
||||||
|
cropY: number
|
||||||
|
cropWidth: number
|
||||||
|
cropHeight: number
|
||||||
|
cropShape: DocsCropShape
|
||||||
|
opacity: number
|
||||||
|
shadow: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOCS_GRAPHIC_DEFAULTS: DocsGraphicAttrs = {
|
||||||
|
graphicType: "image",
|
||||||
|
src: null,
|
||||||
|
alt: "",
|
||||||
|
assetId: null,
|
||||||
|
shapeType: "rect",
|
||||||
|
fill: "#4285f4",
|
||||||
|
stroke: "#1a73e8",
|
||||||
|
strokeWidth: 2,
|
||||||
|
gradientCss: "",
|
||||||
|
gradientAngle: 180,
|
||||||
|
gradientColor1: "#4285f4",
|
||||||
|
gradientColor2: "#34a853",
|
||||||
|
width: 240,
|
||||||
|
height: 160,
|
||||||
|
placement: "block",
|
||||||
|
wrap: "square",
|
||||||
|
floatSide: "left",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
rotationDeg: 0,
|
||||||
|
zIndex: 0,
|
||||||
|
cropX: 0,
|
||||||
|
cropY: 0,
|
||||||
|
cropWidth: 1,
|
||||||
|
cropHeight: 1,
|
||||||
|
cropShape: "rect",
|
||||||
|
opacity: 1,
|
||||||
|
shadow: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOCS_GRAPHIC_WRAP_LABELS: Record<DocsGraphicWrap, string> = {
|
||||||
|
inline: "En ligne avec le texte",
|
||||||
|
square: "Carré",
|
||||||
|
tight: "Rapproché",
|
||||||
|
through: "À travers",
|
||||||
|
"top-bottom": "Haut et bas",
|
||||||
|
behind: "Derrière le texte",
|
||||||
|
"in-front": "Devant le texte",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOCS_GRAPHIC_PLACEMENT_LABELS: Record<DocsGraphicPlacement, string> = {
|
||||||
|
inline: "En ligne",
|
||||||
|
block: "Bloc",
|
||||||
|
absolute: "Position absolue",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGradientCss(
|
||||||
|
angle: number,
|
||||||
|
color1: string,
|
||||||
|
color2: string
|
||||||
|
): string {
|
||||||
|
return `linear-gradient(${angle}deg, ${color1}, ${color2})`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGraphicAttrs(raw: Record<string, unknown>): DocsGraphicAttrs {
|
||||||
|
const num = (key: keyof DocsGraphicAttrs, fallback: number) => {
|
||||||
|
const value = raw[key]
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback
|
||||||
|
}
|
||||||
|
const str = (key: keyof DocsGraphicAttrs, fallback: string) => {
|
||||||
|
const value = raw[key]
|
||||||
|
return typeof value === "string" ? value : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const gradientAngle = num("gradientAngle", DOCS_GRAPHIC_DEFAULTS.gradientAngle)
|
||||||
|
const gradientColor1 = str("gradientColor1", DOCS_GRAPHIC_DEFAULTS.gradientColor1)
|
||||||
|
const gradientColor2 = str("gradientColor2", DOCS_GRAPHIC_DEFAULTS.gradientColor2)
|
||||||
|
const gradientCss =
|
||||||
|
str("gradientCss", "") ||
|
||||||
|
(raw.graphicType === "gradient"
|
||||||
|
? buildGradientCss(gradientAngle, gradientColor1, gradientColor2)
|
||||||
|
: "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
graphicType:
|
||||||
|
raw.graphicType === "shape" || raw.graphicType === "gradient" || raw.graphicType === "image"
|
||||||
|
? raw.graphicType
|
||||||
|
: DOCS_GRAPHIC_DEFAULTS.graphicType,
|
||||||
|
src: typeof raw.src === "string" ? raw.src : null,
|
||||||
|
alt: str("alt", ""),
|
||||||
|
shapeType:
|
||||||
|
raw.shapeType === "ellipse" ||
|
||||||
|
raw.shapeType === "line" ||
|
||||||
|
raw.shapeType === "arrow" ||
|
||||||
|
raw.shapeType === "rect"
|
||||||
|
? raw.shapeType
|
||||||
|
: DOCS_GRAPHIC_DEFAULTS.shapeType,
|
||||||
|
fill: str("fill", DOCS_GRAPHIC_DEFAULTS.fill),
|
||||||
|
stroke: str("stroke", DOCS_GRAPHIC_DEFAULTS.stroke),
|
||||||
|
strokeWidth: num("strokeWidth", DOCS_GRAPHIC_DEFAULTS.strokeWidth),
|
||||||
|
gradientCss,
|
||||||
|
gradientAngle,
|
||||||
|
gradientColor1,
|
||||||
|
gradientColor2,
|
||||||
|
width: Math.max(24, num("width", DOCS_GRAPHIC_DEFAULTS.width)),
|
||||||
|
height: Math.max(24, num("height", DOCS_GRAPHIC_DEFAULTS.height)),
|
||||||
|
placement:
|
||||||
|
raw.placement === "inline" || raw.placement === "block" || raw.placement === "absolute"
|
||||||
|
? raw.placement
|
||||||
|
: DOCS_GRAPHIC_DEFAULTS.placement,
|
||||||
|
wrap:
|
||||||
|
raw.wrap === "inline" ||
|
||||||
|
raw.wrap === "square" ||
|
||||||
|
raw.wrap === "tight" ||
|
||||||
|
raw.wrap === "through" ||
|
||||||
|
raw.wrap === "top-bottom" ||
|
||||||
|
raw.wrap === "behind" ||
|
||||||
|
raw.wrap === "in-front"
|
||||||
|
? raw.wrap
|
||||||
|
: DOCS_GRAPHIC_DEFAULTS.wrap,
|
||||||
|
floatSide:
|
||||||
|
raw.floatSide === "left" || raw.floatSide === "right" || raw.floatSide === "center"
|
||||||
|
? raw.floatSide
|
||||||
|
: DOCS_GRAPHIC_DEFAULTS.floatSide,
|
||||||
|
assetId: typeof raw.assetId === "string" && raw.assetId ? raw.assetId : null,
|
||||||
|
x: num("x", 0),
|
||||||
|
y: num("y", 0),
|
||||||
|
rotationDeg: num("rotationDeg", 0),
|
||||||
|
zIndex: num("zIndex", 0),
|
||||||
|
cropX: clamp01(num("cropX", 0)),
|
||||||
|
cropY: clamp01(num("cropY", 0)),
|
||||||
|
cropWidth: clamp01(num("cropWidth", 1), 1),
|
||||||
|
cropHeight: clamp01(num("cropHeight", 1), 1),
|
||||||
|
cropShape: raw.cropShape === "ellipse" ? "ellipse" : "rect",
|
||||||
|
opacity: clamp01(num("opacity", 1), 1),
|
||||||
|
shadow: str("shadow", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(value: number, fallback = 0): number {
|
||||||
|
if (!Number.isFinite(value)) return fallback
|
||||||
|
return Math.min(1, Math.max(0, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSS styles to render a cropped image inside its frame. */
|
||||||
|
export function computeCropImageStyle(attrs: Pick<
|
||||||
|
DocsGraphicAttrs,
|
||||||
|
"cropX" | "cropY" | "cropWidth" | "cropHeight" | "cropShape"
|
||||||
|
>): { img: Record<string, string | number>; clipPath?: string } {
|
||||||
|
const hasCrop =
|
||||||
|
attrs.cropX > 0 ||
|
||||||
|
attrs.cropY > 0 ||
|
||||||
|
attrs.cropWidth < 1 ||
|
||||||
|
attrs.cropHeight < 1
|
||||||
|
|
||||||
|
if (!hasCrop) return { img: {} }
|
||||||
|
|
||||||
|
const scaleX = 1 / Math.max(attrs.cropWidth, 0.01)
|
||||||
|
const scaleY = 1 / Math.max(attrs.cropHeight, 0.01)
|
||||||
|
const offsetX = -(attrs.cropX * scaleX * 100)
|
||||||
|
const offsetY = -(attrs.cropY * scaleY * 100)
|
||||||
|
|
||||||
|
const img: Record<string, string | number> = {
|
||||||
|
width: `${scaleX * 100}%`,
|
||||||
|
height: `${scaleY * 100}%`,
|
||||||
|
maxWidth: "none",
|
||||||
|
objectFit: "cover",
|
||||||
|
objectPosition: `${offsetX}% ${offsetY}%`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const clipPath =
|
||||||
|
attrs.cropShape === "ellipse" ? "ellipse(50% 50% at 50% 50%)" : undefined
|
||||||
|
|
||||||
|
return { img, clipPath }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imageAttrsToGraphic(raw: Record<string, unknown>): Partial<DocsGraphicAttrs> {
|
||||||
|
const width =
|
||||||
|
typeof raw.width === "number"
|
||||||
|
? raw.width
|
||||||
|
: typeof raw.width === "string"
|
||||||
|
? Number(raw.width) || DOCS_GRAPHIC_DEFAULTS.width
|
||||||
|
: DOCS_GRAPHIC_DEFAULTS.width
|
||||||
|
const height =
|
||||||
|
typeof raw.height === "number"
|
||||||
|
? raw.height
|
||||||
|
: typeof raw.height === "string"
|
||||||
|
? Number(raw.height) || DOCS_GRAPHIC_DEFAULTS.height
|
||||||
|
: DOCS_GRAPHIC_DEFAULTS.height
|
||||||
|
|
||||||
|
return parseGraphicAttrs({
|
||||||
|
graphicType: "image",
|
||||||
|
src: raw.src,
|
||||||
|
alt: raw.alt ?? "",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
placement: raw.placement ?? "inline",
|
||||||
|
wrap: raw.wrap ?? "inline",
|
||||||
|
floatSide: raw.floatSide ?? "left",
|
||||||
|
x: raw.x ?? 0,
|
||||||
|
y: raw.y ?? 0,
|
||||||
|
rotationDeg: raw.rotationDeg ?? 0,
|
||||||
|
zIndex: raw.zIndex ?? 0,
|
||||||
|
cropX: raw.cropX ?? 0,
|
||||||
|
cropY: raw.cropY ?? 0,
|
||||||
|
cropWidth: raw.cropWidth ?? 1,
|
||||||
|
cropHeight: raw.cropHeight ?? 1,
|
||||||
|
cropShape: raw.cropShape ?? "rect",
|
||||||
|
assetId: raw.assetId ?? null,
|
||||||
|
opacity: raw.opacity ?? 1,
|
||||||
|
shadow: raw.shadow ?? "",
|
||||||
|
})
|
||||||
|
}
|
||||||
76
lib/drive/docs-graphic.test.ts
Normal file
76
lib/drive/docs-graphic.test.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
import { computeGraphicLayoutStyle, resizeWithHandle } from "./docs-graphic-layout.ts"
|
||||||
|
import { normalizeImportedGraphics } from "./docs-graphic-import.ts"
|
||||||
|
import { DOCS_GRAPHIC_DEFAULTS } from "./docs-graphic-types.ts"
|
||||||
|
|
||||||
|
describe("docs-graphic", () => {
|
||||||
|
it("computeGraphicLayoutStyle applies float wrap", () => {
|
||||||
|
const layout = computeGraphicLayoutStyle({
|
||||||
|
...DOCS_GRAPHIC_DEFAULTS,
|
||||||
|
graphicType: "image",
|
||||||
|
wrap: "square",
|
||||||
|
floatSide: "left",
|
||||||
|
})
|
||||||
|
assert.equal(layout.inner.float, "left")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("computeGraphicLayoutStyle applies absolute placement", () => {
|
||||||
|
const layout = computeGraphicLayoutStyle({
|
||||||
|
...DOCS_GRAPHIC_DEFAULTS,
|
||||||
|
graphicType: "shape",
|
||||||
|
placement: "absolute",
|
||||||
|
x: 40,
|
||||||
|
y: 20,
|
||||||
|
})
|
||||||
|
assert.equal(layout.inner.position, "absolute")
|
||||||
|
assert.equal(layout.inner.left, 40)
|
||||||
|
assert.equal(layout.inner.top, 20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("resizeWithHandle respects minimum size", () => {
|
||||||
|
const next = resizeWithHandle("se", 120, 80, -200, -200)
|
||||||
|
assert.equal(next.width, 24)
|
||||||
|
assert.equal(next.height, 24)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("resizeWithHandle respects aspect lock", () => {
|
||||||
|
const next = resizeWithHandle("se", 200, 100, 100, 50, 24, true)
|
||||||
|
assert.equal(next.width, 300)
|
||||||
|
assert.equal(next.height, 150)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("normalizeImportedGraphics upgrades standalone image paragraph", () => {
|
||||||
|
const result = normalizeImportedGraphics({
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "image", attrs: { src: "data:image/png;base64,abc", width: 200 } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert.equal(result.content?.[0]?.type, "docsGraphic")
|
||||||
|
assert.equal((result.content?.[0]?.attrs as { graphicType?: string }).graphicType, "image")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("normalizeImportedGraphics maps docx wrap aliases", () => {
|
||||||
|
const result = normalizeImportedGraphics({
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "docsGraphic",
|
||||||
|
attrs: {
|
||||||
|
graphicType: "image",
|
||||||
|
src: "https://example.com/a.png",
|
||||||
|
wrap: "topAndBottom",
|
||||||
|
placement: "anchored",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const attrs = result.content?.[0]?.attrs as { wrap?: string; placement?: string }
|
||||||
|
assert.equal(attrs.wrap, "top-bottom")
|
||||||
|
assert.equal(attrs.placement, "absolute")
|
||||||
|
})
|
||||||
|
})
|
||||||
203
lib/drive/docs-header-footer-layout.ts
Normal file
203
lib/drive/docs-header-footer-layout.ts
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
import type { DocPageHeaderFooter, DocPageLayout, DocPageSetup } from "./doc-page-setup.ts"
|
||||||
|
import { mmToPx, pxToMm } from "./doc-page-setup.ts"
|
||||||
|
|
||||||
|
export type DocsHeaderFooterRegion = "header" | "footer"
|
||||||
|
|
||||||
|
/** Height of the Google Docs–style header/footer chrome bar (px). */
|
||||||
|
export const DOCS_HF_CHROME_BAR_PX = 28
|
||||||
|
|
||||||
|
export function headerOffsetPx(pageLayout: DocPageLayout): number {
|
||||||
|
return pageLayout.headerMarginMm != null
|
||||||
|
? mmToPx(pageLayout.headerMarginMm)
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function footerOffsetPx(pageLayout: DocPageLayout): number {
|
||||||
|
return pageLayout.footerMarginMm != null
|
||||||
|
? mmToPx(pageLayout.footerMarginMm)
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultHeaderZoneHeightPx(pageLayout: DocPageLayout): number {
|
||||||
|
return Math.max(24, pageLayout.marginsPx.top - headerOffsetPx(pageLayout))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultFooterZoneHeightPx(pageLayout: DocPageLayout): number {
|
||||||
|
return Math.max(24, pageLayout.marginsPx.bottom - footerOffsetPx(pageLayout))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function regionZoneHeightPx(
|
||||||
|
region: DocPageHeaderFooter | null | undefined,
|
||||||
|
defaultPx: number
|
||||||
|
): number {
|
||||||
|
if (region?.heightMm != null && region.heightMm > 0) {
|
||||||
|
return Math.max(defaultPx, mmToPx(region.heightMm))
|
||||||
|
}
|
||||||
|
return defaultPx
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header/footer content shown on a given page index. */
|
||||||
|
export function resolveRegionForPage(
|
||||||
|
pageLayout: DocPageLayout,
|
||||||
|
region: DocsHeaderFooterRegion,
|
||||||
|
pageIndex: number
|
||||||
|
): DocPageHeaderFooter | null | undefined {
|
||||||
|
const differentFirst = pageLayout.headerFooterDifferentFirstPage ?? false
|
||||||
|
if (region === "header") {
|
||||||
|
if (pageIndex === 0 && differentFirst) return pageLayout.headerFirstPage ?? null
|
||||||
|
return pageLayout.header
|
||||||
|
}
|
||||||
|
if (pageIndex === 0 && differentFirst) return pageLayout.footerFirstPage ?? null
|
||||||
|
return pageLayout.footer
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveTopMarginPx(pageLayout: DocPageLayout): number {
|
||||||
|
const offset = headerOffsetPx(pageLayout)
|
||||||
|
const defaultZone = defaultHeaderZoneHeightPx(pageLayout)
|
||||||
|
const sharedH = regionZoneHeightPx(pageLayout.header, defaultZone)
|
||||||
|
const firstH = pageLayout.headerFooterDifferentFirstPage
|
||||||
|
? regionZoneHeightPx(pageLayout.headerFirstPage, defaultZone)
|
||||||
|
: sharedH
|
||||||
|
return Math.max(pageLayout.marginsPx.top, offset + Math.max(sharedH, firstH))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveBottomMarginPx(pageLayout: DocPageLayout): number {
|
||||||
|
const offset = footerOffsetPx(pageLayout)
|
||||||
|
const defaultZone = defaultFooterZoneHeightPx(pageLayout)
|
||||||
|
const sharedH = regionZoneHeightPx(pageLayout.footer, defaultZone)
|
||||||
|
const firstH = pageLayout.headerFooterDifferentFirstPage
|
||||||
|
? regionZoneHeightPx(pageLayout.footerFirstPage, defaultZone)
|
||||||
|
: sharedH
|
||||||
|
return Math.max(pageLayout.marginsPx.bottom, offset + Math.max(sharedH, firstH))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveMarginsPx(
|
||||||
|
pageLayout: DocPageLayout,
|
||||||
|
livePreview?: { region: DocsHeaderFooterRegion; heightPx: number } | null,
|
||||||
|
measuredHeights?: Partial<Record<DocsHeaderFooterRegion, number>>
|
||||||
|
) {
|
||||||
|
let top = effectiveTopMarginPx(pageLayout)
|
||||||
|
let bottom = effectiveBottomMarginPx(pageLayout)
|
||||||
|
|
||||||
|
if (measuredHeights?.header != null) {
|
||||||
|
const offset = headerOffsetPx(pageLayout)
|
||||||
|
top = Math.max(pageLayout.marginsPx.top, offset + measuredHeights.header)
|
||||||
|
}
|
||||||
|
if (measuredHeights?.footer != null) {
|
||||||
|
const offset = footerOffsetPx(pageLayout)
|
||||||
|
bottom = Math.max(pageLayout.marginsPx.bottom, offset + measuredHeights.footer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (livePreview) {
|
||||||
|
const { region, heightPx } = livePreview
|
||||||
|
if (region === "header") {
|
||||||
|
const offset = headerOffsetPx(pageLayout)
|
||||||
|
top = Math.max(pageLayout.marginsPx.top, offset + heightPx)
|
||||||
|
} else {
|
||||||
|
const offset = footerOffsetPx(pageLayout)
|
||||||
|
bottom = Math.max(pageLayout.marginsPx.bottom, offset + heightPx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
top,
|
||||||
|
right: pageLayout.marginsPx.right,
|
||||||
|
bottom,
|
||||||
|
left: pageLayout.marginsPx.left,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageRegionZoneHeightPx(
|
||||||
|
pageLayout: DocPageLayout,
|
||||||
|
region: DocsHeaderFooterRegion,
|
||||||
|
pageIndex: number
|
||||||
|
): number {
|
||||||
|
const data = resolveRegionForPage(pageLayout, region, pageIndex)
|
||||||
|
const defaultPx =
|
||||||
|
region === "header"
|
||||||
|
? defaultHeaderZoneHeightPx(pageLayout)
|
||||||
|
: defaultFooterZoneHeightPx(pageLayout)
|
||||||
|
return regionZoneHeightPx(data, defaultPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageHeaderGeometry(
|
||||||
|
pageLayout: DocPageLayout,
|
||||||
|
pageTop: number,
|
||||||
|
pageIndex: number
|
||||||
|
) {
|
||||||
|
const offset = headerOffsetPx(pageLayout)
|
||||||
|
const zoneHeight = pageRegionZoneHeightPx(pageLayout, "header", pageIndex)
|
||||||
|
const zoneTop = pageTop + offset
|
||||||
|
return {
|
||||||
|
zoneTop,
|
||||||
|
zoneHeight,
|
||||||
|
bottomLine: zoneTop + zoneHeight,
|
||||||
|
offset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageFooterGeometry(
|
||||||
|
pageLayout: DocPageLayout,
|
||||||
|
pageTop: number,
|
||||||
|
pageHeight: number,
|
||||||
|
pageIndex: number
|
||||||
|
) {
|
||||||
|
const offset = footerOffsetPx(pageLayout)
|
||||||
|
const zoneHeight = pageRegionZoneHeightPx(pageLayout, "footer", pageIndex)
|
||||||
|
const zoneBottom = pageTop + pageHeight - offset
|
||||||
|
return {
|
||||||
|
zoneTop: zoneBottom - zoneHeight,
|
||||||
|
zoneHeight,
|
||||||
|
zoneBottom,
|
||||||
|
topLine: zoneBottom - zoneHeight,
|
||||||
|
offset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function regionStorageKey(
|
||||||
|
region: DocsHeaderFooterRegion,
|
||||||
|
pageIndex: number,
|
||||||
|
differentFirstPage: boolean
|
||||||
|
): keyof DocPageSetup {
|
||||||
|
if (pageIndex === 0 && differentFirstPage) {
|
||||||
|
return region === "header" ? "headerFirstPage" : "footerFirstPage"
|
||||||
|
}
|
||||||
|
return region
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRegionPatch(
|
||||||
|
setup: DocPageSetup,
|
||||||
|
region: DocsHeaderFooterRegion,
|
||||||
|
pageIndex: number,
|
||||||
|
content: Record<string, unknown>,
|
||||||
|
contentHeightPx: number
|
||||||
|
): Partial<DocPageSetup> {
|
||||||
|
const differentFirst = setup.headerFooterDifferentFirstPage ?? false
|
||||||
|
const key = regionStorageKey(region, pageIndex, differentFirst)
|
||||||
|
const topPx = mmToPx(setup.marginsMm.top)
|
||||||
|
const bottomPx = mmToPx(setup.marginsMm.bottom)
|
||||||
|
const headerOff =
|
||||||
|
setup.headerMarginMm != null ? mmToPx(setup.headerMarginMm) : 0
|
||||||
|
const footerOff =
|
||||||
|
setup.footerMarginMm != null ? mmToPx(setup.footerMarginMm) : 0
|
||||||
|
const defaultZonePx =
|
||||||
|
region === "header" ? Math.max(24, topPx - headerOff) : Math.max(24, bottomPx - footerOff)
|
||||||
|
const heightMm = pxToMm(Math.max(defaultZonePx, contentHeightPx))
|
||||||
|
return {
|
||||||
|
[key]: { content, heightMm },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleDifferentFirstPage(
|
||||||
|
setup: DocPageSetup,
|
||||||
|
enabled: boolean
|
||||||
|
): Partial<DocPageSetup> {
|
||||||
|
if (enabled) {
|
||||||
|
return {
|
||||||
|
headerFooterDifferentFirstPage: true,
|
||||||
|
headerFirstPage: setup.headerFirstPage ?? setup.header ?? null,
|
||||||
|
footerFirstPage: setup.footerFirstPage ?? setup.footer ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { headerFooterDifferentFirstPage: false }
|
||||||
|
}
|
||||||
@ -10,8 +10,9 @@ export type DocsShortcutId =
|
|||||||
| "edit.pastePlain"
|
| "edit.pastePlain"
|
||||||
| "edit.selectAll"
|
| "edit.selectAll"
|
||||||
| "edit.findReplace"
|
| "edit.findReplace"
|
||||||
|
| "view.showNonPrintable"
|
||||||
|
|
||||||
export type DocsShortcutCategory = "file" | "edit"
|
export type DocsShortcutCategory = "file" | "edit" | "view"
|
||||||
|
|
||||||
/** Where the shortcut is handled. */
|
/** Where the shortcut is handled. */
|
||||||
export type DocsShortcutScope = "document" | "editor"
|
export type DocsShortcutScope = "document" | "editor"
|
||||||
@ -124,6 +125,14 @@ export const DOCS_KEYBOARD_SHORTCUT_DEFINITIONS = [
|
|||||||
handler: "custom",
|
handler: "custom",
|
||||||
defaultBinding: { key: "h", mod: true, shift: true },
|
defaultBinding: { key: "h", mod: true, shift: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "view.showNonPrintable",
|
||||||
|
label: "Afficher les caractères non imprimables",
|
||||||
|
category: "view",
|
||||||
|
scope: "document",
|
||||||
|
handler: "custom",
|
||||||
|
defaultBinding: { key: "p", mod: true, shift: true },
|
||||||
|
},
|
||||||
] as const satisfies readonly DocsShortcutDefinition[]
|
] as const satisfies readonly DocsShortcutDefinition[]
|
||||||
|
|
||||||
export const DOCS_KEYBOARD_SHORTCUTS_BY_ID: Record<
|
export const DOCS_KEYBOARD_SHORTCUTS_BY_ID: Record<
|
||||||
|
|||||||
33
lib/drive/docs-page-flow.test.ts
Normal file
33
lib/drive/docs-page-flow.test.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
import { countPageFlowSpacers } from "./extensions/docs-page-flow-decoration.ts"
|
||||||
|
|
||||||
|
describe("docs-page-flow spacers", () => {
|
||||||
|
const bodyAreaH = 900
|
||||||
|
const interPageSpacer = 200
|
||||||
|
|
||||||
|
it("inserts no spacer when content fits page 1", () => {
|
||||||
|
assert.equal(countPageFlowSpacers([{ height: 400 }], bodyAreaH, interPageSpacer), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("inserts one spacer when a block crosses page 1", () => {
|
||||||
|
assert.equal(
|
||||||
|
countPageFlowSpacers([{ height: 850 }, { height: 100 }], bodyAreaH, interPageSpacer),
|
||||||
|
1
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not insert spacers for blocks already past page 1 boundary", () => {
|
||||||
|
assert.equal(
|
||||||
|
countPageFlowSpacers([{ height: 40 }, { height: 40 }], bodyAreaH, interPageSpacer),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("inserts one spacer per crossing block in a layout pass", () => {
|
||||||
|
assert.equal(
|
||||||
|
countPageFlowSpacers([{ height: 1300 }], bodyAreaH, interPageSpacer),
|
||||||
|
1
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
8
lib/drive/docs-page-layout-constants.ts
Normal file
8
lib/drive/docs-page-layout-constants.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** Gap between stacked pages in print layout (px, unscaled). */
|
||||||
|
export const DOCS_PAGE_GAP_PX = 12
|
||||||
|
|
||||||
|
export const DOCS_CANVAS_PADDING_Y_PX = 32
|
||||||
|
export const DOCS_CANVAS_PADDING_TOP_NARROW_PX = 0
|
||||||
|
|
||||||
|
export const DOCS_HORIZONTAL_RULER_HEIGHT_PX = 20
|
||||||
|
export const DOCS_VERTICAL_RULER_WIDTH_PX = 28
|
||||||
36
lib/drive/docs-page-metrics.test.ts
Normal file
36
lib/drive/docs-page-metrics.test.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
import {
|
||||||
|
computePageCount,
|
||||||
|
computePageMetrics,
|
||||||
|
computeProseMinHeight,
|
||||||
|
computeStackHeight,
|
||||||
|
} from "./docs-page-metrics.ts"
|
||||||
|
|
||||||
|
describe("docs-page-metrics", () => {
|
||||||
|
const metrics = computePageMetrics({
|
||||||
|
widthPx: 794,
|
||||||
|
heightPx: 1122,
|
||||||
|
marginsPx: { top: 96, right: 96, bottom: 96, left: 96 },
|
||||||
|
headerMarginMm: 12.7,
|
||||||
|
footerMarginMm: 12.7,
|
||||||
|
})
|
||||||
|
|
||||||
|
it("computes body area height from margins", () => {
|
||||||
|
assert.equal(metrics.bodyAreaHeight, 1122 - 96 - 96)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("computes page count from content height", () => {
|
||||||
|
assert.equal(computePageCount(400, metrics), 1)
|
||||||
|
assert.equal(computePageCount(metrics.bodyAreaHeight + 1, metrics), 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("computes stack height with page gaps", () => {
|
||||||
|
assert.equal(computeStackHeight(2, 1122), 1122 * 2 + 12)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("computes prose min height with inter-page spacers", () => {
|
||||||
|
const minH = computeProseMinHeight(2, metrics)
|
||||||
|
assert.equal(minH, metrics.bodyAreaHeight * 2 + metrics.interPageSpacer)
|
||||||
|
})
|
||||||
|
})
|
||||||
92
lib/drive/docs-page-metrics.ts
Normal file
92
lib/drive/docs-page-metrics.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import type { DocPageSetup } from "./doc-page-setup.ts"
|
||||||
|
import { DOCS_PAGE_GAP_PX } from "./docs-page-layout-constants.ts"
|
||||||
|
import { mmToPx } from "./doc-page-setup.ts"
|
||||||
|
|
||||||
|
export type DocsPageMetrics = {
|
||||||
|
pageWidth: number
|
||||||
|
pageHeight: number
|
||||||
|
margins: { top: number; right: number; bottom: number; left: number }
|
||||||
|
headerMarginPx: number
|
||||||
|
footerMarginPx: number
|
||||||
|
bodyAreaHeight: number
|
||||||
|
interPageSpacer: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computePageMetrics(pageLayout: {
|
||||||
|
widthPx: number
|
||||||
|
heightPx: number
|
||||||
|
marginsPx: { top: number; right: number; bottom: number; left: number }
|
||||||
|
headerMarginMm?: number
|
||||||
|
footerMarginMm?: number
|
||||||
|
effectiveMarginsPx?: { top: number; right: number; bottom: number; left: number }
|
||||||
|
}): DocsPageMetrics {
|
||||||
|
const margins = pageLayout.effectiveMarginsPx ?? pageLayout.marginsPx
|
||||||
|
const headerMarginPx =
|
||||||
|
pageLayout.headerMarginMm != null
|
||||||
|
? mmToPx(pageLayout.headerMarginMm)
|
||||||
|
: pageLayout.marginsPx.top
|
||||||
|
const footerMarginPx =
|
||||||
|
pageLayout.footerMarginMm != null
|
||||||
|
? mmToPx(pageLayout.footerMarginMm)
|
||||||
|
: pageLayout.marginsPx.bottom
|
||||||
|
|
||||||
|
const bodyAreaHeight = pageLayout.heightPx - margins.top - margins.bottom
|
||||||
|
const interPageSpacer =
|
||||||
|
margins.bottom + DOCS_PAGE_GAP_PX + margins.top
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageWidth: pageLayout.widthPx,
|
||||||
|
pageHeight: pageLayout.heightPx,
|
||||||
|
margins,
|
||||||
|
headerMarginPx,
|
||||||
|
footerMarginPx,
|
||||||
|
bodyAreaHeight: Math.max(1, bodyAreaHeight),
|
||||||
|
interPageSpacer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page count from prose content height (excludes outer surface padding). */
|
||||||
|
export function computePageCount(contentHeight: number, metrics: DocsPageMetrics): number {
|
||||||
|
if (contentHeight <= 0) return 1
|
||||||
|
const { bodyAreaHeight, interPageSpacer } = metrics
|
||||||
|
let remaining = contentHeight
|
||||||
|
let pages = 1
|
||||||
|
while (remaining > bodyAreaHeight) {
|
||||||
|
remaining -= bodyAreaHeight
|
||||||
|
if (remaining <= 0) break
|
||||||
|
remaining -= interPageSpacer
|
||||||
|
pages += 1
|
||||||
|
}
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeStackHeight(pageCount: number, pageHeight: number): number {
|
||||||
|
return pageCount * pageHeight + Math.max(0, pageCount - 1) * DOCS_PAGE_GAP_PX
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimum prose height to align with visual page stack including inter-page spacers. */
|
||||||
|
export function computeProseMinHeight(pageCount: number, metrics: DocsPageMetrics): number {
|
||||||
|
const { bodyAreaHeight, interPageSpacer } = metrics
|
||||||
|
return (
|
||||||
|
pageCount * bodyAreaHeight + Math.max(0, pageCount - 1) * interPageSpacer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyRegionDoc() {
|
||||||
|
return { type: "doc", content: [{ type: "paragraph" }] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageSetupFromMetrics(
|
||||||
|
setup: DocPageSetup | null | undefined,
|
||||||
|
patch: Partial<DocPageSetup>
|
||||||
|
): DocPageSetup {
|
||||||
|
return { ...(setup ?? defaultMinimalSetup()), ...patch }
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultMinimalSetup(): DocPageSetup {
|
||||||
|
return {
|
||||||
|
widthMm: 210,
|
||||||
|
heightMm: 297,
|
||||||
|
marginsMm: { top: 25.4, right: 25.4, bottom: 25.4, left: 25.4 },
|
||||||
|
}
|
||||||
|
}
|
||||||
73
lib/drive/docs-ruler-margin-math.ts
Normal file
73
lib/drive/docs-ruler-margin-math.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
export const DOCS_RULER_MIN_MARGIN_PX = 12
|
||||||
|
export const DOCS_RULER_MIN_BODY_PX = 48
|
||||||
|
|
||||||
|
export type DocsRulerMarginSide = "left" | "right" | "top" | "bottom"
|
||||||
|
|
||||||
|
export type DocsPageMarginsPx = {
|
||||||
|
top: number
|
||||||
|
right: number
|
||||||
|
bottom: number
|
||||||
|
left: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampPageMarginPx(
|
||||||
|
side: DocsRulerMarginSide,
|
||||||
|
valuePx: number,
|
||||||
|
margins: DocsPageMarginsPx,
|
||||||
|
pageWidth: number,
|
||||||
|
pageHeight: number
|
||||||
|
): number {
|
||||||
|
const min = DOCS_RULER_MIN_MARGIN_PX
|
||||||
|
const bodyMin = DOCS_RULER_MIN_BODY_PX
|
||||||
|
|
||||||
|
switch (side) {
|
||||||
|
case "left":
|
||||||
|
return Math.round(
|
||||||
|
Math.max(min, Math.min(valuePx, pageWidth - margins.right - bodyMin))
|
||||||
|
)
|
||||||
|
case "right": {
|
||||||
|
const right = Math.round(
|
||||||
|
Math.max(min, Math.min(valuePx, pageWidth - margins.left - bodyMin))
|
||||||
|
)
|
||||||
|
return right
|
||||||
|
}
|
||||||
|
case "top":
|
||||||
|
return Math.round(
|
||||||
|
Math.max(min, Math.min(valuePx, pageHeight - margins.bottom - bodyMin))
|
||||||
|
)
|
||||||
|
case "bottom": {
|
||||||
|
const bottom = Math.round(
|
||||||
|
Math.max(min, Math.min(valuePx, pageHeight - margins.top - bodyMin))
|
||||||
|
)
|
||||||
|
return bottom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Horizontal ruler X → left margin px. */
|
||||||
|
export function pagePxFromHorizontalPointer(
|
||||||
|
pointerX: number,
|
||||||
|
rulerLeft: number,
|
||||||
|
scale: number
|
||||||
|
): number {
|
||||||
|
if (scale <= 0) return 0
|
||||||
|
return (pointerX - rulerLeft) / scale
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vertical ruler Y → distance from page top px. */
|
||||||
|
export function pagePxFromVerticalPointer(
|
||||||
|
pointerY: number,
|
||||||
|
rulerTop: number,
|
||||||
|
scale: number
|
||||||
|
): number {
|
||||||
|
if (scale <= 0) return 0
|
||||||
|
return (pointerY - rulerTop) / scale
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeMarginPreview(
|
||||||
|
margins: DocsPageMarginsPx,
|
||||||
|
preview: Partial<DocsPageMarginsPx> | null | undefined
|
||||||
|
): DocsPageMarginsPx {
|
||||||
|
if (!preview) return margins
|
||||||
|
return { ...margins, ...preview }
|
||||||
|
}
|
||||||
26
lib/drive/docs-ruler-margin.test.ts
Normal file
26
lib/drive/docs-ruler-margin.test.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
import { clampPageMarginPx } from "./docs-ruler-margin-math.ts"
|
||||||
|
|
||||||
|
describe("docs-ruler-margin-math", () => {
|
||||||
|
const pageWidth = 794
|
||||||
|
const pageHeight = 1122
|
||||||
|
const margins = { top: 96, right: 96, bottom: 96, left: 96 }
|
||||||
|
|
||||||
|
it("clamps left margin against right margin and min body", () => {
|
||||||
|
assert.equal(clampPageMarginPx("left", 400, margins, pageWidth, pageHeight), 400)
|
||||||
|
assert.equal(clampPageMarginPx("left", 700, margins, pageWidth, pageHeight), 650)
|
||||||
|
assert.equal(clampPageMarginPx("left", 0, margins, pageWidth, pageHeight), 12)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("clamps right margin from pointer position", () => {
|
||||||
|
assert.equal(clampPageMarginPx("right", 120, margins, pageWidth, pageHeight), 120)
|
||||||
|
assert.equal(clampPageMarginPx("right", 700, margins, pageWidth, pageHeight), 650)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("clamps top and bottom margins", () => {
|
||||||
|
assert.equal(clampPageMarginPx("top", 48, margins, pageWidth, pageHeight), 48)
|
||||||
|
assert.equal(clampPageMarginPx("bottom", 80, margins, pageWidth, pageHeight), 80)
|
||||||
|
assert.equal(clampPageMarginPx("top", 900, margins, pageWidth, pageHeight), 978)
|
||||||
|
})
|
||||||
|
})
|
||||||
65
lib/drive/docs-ruler-math.ts
Normal file
65
lib/drive/docs-ruler-math.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import type { PageFormatId } from "@/lib/drive/page-formats"
|
||||||
|
import {
|
||||||
|
formatRulerMajorLabel,
|
||||||
|
getRulerUnitConfig,
|
||||||
|
} from "@/lib/drive/docs-ruler-units"
|
||||||
|
|
||||||
|
export type DocsRulerTick = {
|
||||||
|
/** Position in page px. */
|
||||||
|
pos: number
|
||||||
|
major: boolean
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRulerTicksAlongAxis(
|
||||||
|
lengthPx: number,
|
||||||
|
formatId: PageFormatId,
|
||||||
|
originOffsetPx = 0
|
||||||
|
): DocsRulerTick[] {
|
||||||
|
const { unit, majorStepPx, minorDivisions } = getRulerUnitConfig(formatId)
|
||||||
|
const minorStep = majorStepPx / minorDivisions
|
||||||
|
const ticks: DocsRulerTick[] = []
|
||||||
|
|
||||||
|
const startMajor = -Math.ceil(originOffsetPx / majorStepPx)
|
||||||
|
const endMajor = Math.ceil(lengthPx / majorStepPx)
|
||||||
|
|
||||||
|
for (let major = startMajor; major <= endMajor; major += 1) {
|
||||||
|
const pos = major * majorStepPx
|
||||||
|
if (pos > lengthPx + 0.5) break
|
||||||
|
|
||||||
|
const unitValue = major
|
||||||
|
ticks.push({
|
||||||
|
pos,
|
||||||
|
major: true,
|
||||||
|
label:
|
||||||
|
unitValue !== 0 || originOffsetPx > 0
|
||||||
|
? formatRulerMajorLabel(unitValue, unit)
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (pos < 0) continue
|
||||||
|
|
||||||
|
for (let minor = 1; minor < minorDivisions; minor += 1) {
|
||||||
|
const tickPos = pos + minor * minorStep
|
||||||
|
if (tickPos > lengthPx) break
|
||||||
|
ticks.push({ pos: tickPos, major: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ticks
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHorizontalRulerTicks(
|
||||||
|
lengthPx: number,
|
||||||
|
formatId: PageFormatId
|
||||||
|
): DocsRulerTick[] {
|
||||||
|
return buildRulerTicksAlongAxis(lengthPx, formatId, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildVerticalRulerTicks(
|
||||||
|
pageHeightPx: number,
|
||||||
|
topMarginPx: number,
|
||||||
|
formatId: PageFormatId
|
||||||
|
): DocsRulerTick[] {
|
||||||
|
return buildRulerTicksAlongAxis(pageHeightPx, formatId, topMarginPx)
|
||||||
|
}
|
||||||
17
lib/drive/docs-ruler-scale.ts
Normal file
17
lib/drive/docs-ruler-scale.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { MM_TO_PX } from "@/lib/drive/page-formats"
|
||||||
|
|
||||||
|
/** One inch in page coordinate space (matches CSS print px). */
|
||||||
|
export const DOCS_RULER_INCH_PX = MM_TO_PX * 25.4
|
||||||
|
|
||||||
|
export function docsZoomToScale(zoom: number): number {
|
||||||
|
return zoom / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
export function docsPageLengthToScreen(lengthPx: number, scale: number): number {
|
||||||
|
return lengthPx * scale
|
||||||
|
}
|
||||||
|
|
||||||
|
export function docsScreenLengthToPage(lengthPx: number, scale: number): number {
|
||||||
|
if (scale <= 0) return lengthPx
|
||||||
|
return lengthPx / scale
|
||||||
|
}
|
||||||
32
lib/drive/docs-ruler-sync-math.ts
Normal file
32
lib/drive/docs-ruler-sync-math.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
/** Page containing the vertical center of the canvas viewport (0-based). */
|
||||||
|
export function resolveCurrentPageInViewport(
|
||||||
|
stackTopInViewport: number,
|
||||||
|
viewportHeight: number,
|
||||||
|
pageStride: number,
|
||||||
|
pageCount: number
|
||||||
|
): number {
|
||||||
|
if (pageCount <= 1 || pageStride <= 0 || viewportHeight <= 0) return 0
|
||||||
|
const centerInStack = viewportHeight / 2 - stackTopInViewport
|
||||||
|
const index = Math.floor(centerInStack / pageStride)
|
||||||
|
return Math.min(pageCount - 1, Math.max(0, index))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use resolveCurrentPageInViewport — kept for tests comparing old behaviour. */
|
||||||
|
export function resolveCurrentPageAtViewportTop(
|
||||||
|
stackTopInViewport: number,
|
||||||
|
pageStride: number,
|
||||||
|
pageCount: number
|
||||||
|
): number {
|
||||||
|
if (pageCount <= 1 || pageStride <= 0) return 0
|
||||||
|
if (stackTopInViewport >= 0) return 0
|
||||||
|
const index = Math.floor(-stackTopInViewport / pageStride)
|
||||||
|
return Math.min(pageCount - 1, Math.max(0, index))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computePageTopInViewport(
|
||||||
|
stackTopInViewport: number,
|
||||||
|
currentPage: number,
|
||||||
|
pageStride: number
|
||||||
|
): number {
|
||||||
|
return stackTopInViewport + currentPage * pageStride
|
||||||
|
}
|
||||||
37
lib/drive/docs-ruler-sync.test.ts
Normal file
37
lib/drive/docs-ruler-sync.test.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
import {
|
||||||
|
computePageTopInViewport,
|
||||||
|
resolveCurrentPageAtViewportTop,
|
||||||
|
resolveCurrentPageInViewport,
|
||||||
|
} from "./docs-ruler-sync-math.ts"
|
||||||
|
|
||||||
|
describe("docs ruler vertical sync", () => {
|
||||||
|
const stride = 1000
|
||||||
|
const viewport = 800
|
||||||
|
|
||||||
|
it("keeps page 0 while viewport center is on page 1", () => {
|
||||||
|
assert.equal(resolveCurrentPageInViewport(48, viewport, stride, 3), 0)
|
||||||
|
assert.equal(computePageTopInViewport(48, 0, stride), 48)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("advances page when viewport center crosses page boundary", () => {
|
||||||
|
// centerInStack = 400 - (-700) = 1100 → page 1
|
||||||
|
assert.equal(resolveCurrentPageInViewport(-700, viewport, stride, 3), 1)
|
||||||
|
assert.equal(computePageTopInViewport(-700, 1, stride), 300)
|
||||||
|
|
||||||
|
// Old top-edge rule still on page 0 here
|
||||||
|
assert.equal(resolveCurrentPageAtViewportTop(-700, stride, 3), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("switches before page 1 fully leaves viewport (vs top-edge rule)", () => {
|
||||||
|
// centerInStack = 400 - (-750) = 1150 → page 1; page 1 bottom still visible
|
||||||
|
assert.equal(resolveCurrentPageInViewport(-750, viewport, stride, 2), 1)
|
||||||
|
assert.equal(resolveCurrentPageAtViewportTop(-750, stride, 2), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("clamps to last page", () => {
|
||||||
|
assert.equal(resolveCurrentPageInViewport(-5000, viewport, stride, 3), 2)
|
||||||
|
assert.equal(computePageTopInViewport(-5000, 2, stride), -3000)
|
||||||
|
})
|
||||||
|
})
|
||||||
58
lib/drive/docs-ruler-units.ts
Normal file
58
lib/drive/docs-ruler-units.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { MM_TO_PX, type PageFormatId } from "@/lib/drive/page-formats"
|
||||||
|
import { DOCS_RULER_INCH_PX } from "@/lib/drive/docs-ruler-scale"
|
||||||
|
|
||||||
|
export type DocsRulerUnit = "cm" | "inch"
|
||||||
|
|
||||||
|
/** One centimetre in page coordinate space. */
|
||||||
|
export const DOCS_RULER_CM_PX = MM_TO_PX * 10
|
||||||
|
|
||||||
|
const INCH_FORMAT_IDS = new Set<PageFormatId>(["letter", "legal", "tabloid"])
|
||||||
|
const CM_FORMAT_IDS = new Set<PageFormatId>(["a4", "a5"])
|
||||||
|
|
||||||
|
export function resolveRulerUnit(formatId: PageFormatId): DocsRulerUnit {
|
||||||
|
if (INCH_FORMAT_IDS.has(formatId)) return "inch"
|
||||||
|
if (CM_FORMAT_IDS.has(formatId)) return "cm"
|
||||||
|
return "cm"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocsRulerUnitConfig = {
|
||||||
|
unit: DocsRulerUnit
|
||||||
|
majorStepPx: number
|
||||||
|
minorDivisions: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRulerUnitConfig(formatId: PageFormatId): DocsRulerUnitConfig {
|
||||||
|
const unit = resolveRulerUnit(formatId)
|
||||||
|
if (unit === "inch") {
|
||||||
|
return {
|
||||||
|
unit,
|
||||||
|
majorStepPx: DOCS_RULER_INCH_PX,
|
||||||
|
minorDivisions: 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
unit,
|
||||||
|
majorStepPx: DOCS_RULER_CM_PX,
|
||||||
|
minorDivisions: 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRulerMajorLabel(value: number, unit: DocsRulerUnit): string {
|
||||||
|
if (value === 0) return "0"
|
||||||
|
const abs = Math.abs(value)
|
||||||
|
if (unit === "cm") {
|
||||||
|
return Number.isInteger(abs) ? String(value) : value.toFixed(1).replace(".", ",")
|
||||||
|
}
|
||||||
|
return Number.isInteger(abs) ? String(value) : String(Math.round(value * 10) / 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Margin distance from page edge, in the ruler unit for the page format. */
|
||||||
|
export function formatMarginDistanceLabel(
|
||||||
|
marginPx: number,
|
||||||
|
formatId: PageFormatId
|
||||||
|
): string {
|
||||||
|
const { unit, majorStepPx } = getRulerUnitConfig(formatId)
|
||||||
|
const value = marginPx / majorStepPx
|
||||||
|
const formatted = formatRulerMajorLabel(value, unit)
|
||||||
|
return unit === "inch" ? `${formatted}"` : `${formatted} cm`
|
||||||
|
}
|
||||||
43
lib/drive/docs-ruler.test.ts
Normal file
43
lib/drive/docs-ruler.test.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
import { buildHorizontalRulerTicks } from "@/lib/drive/docs-ruler-math"
|
||||||
|
import { getRulerUnitConfig, resolveRulerUnit, formatMarginDistanceLabel } from "@/lib/drive/docs-ruler-units"
|
||||||
|
import { MM_TO_PX } from "@/lib/drive/page-formats"
|
||||||
|
|
||||||
|
describe("docs ruler units", () => {
|
||||||
|
it("uses cm for A4 and inches for Letter", () => {
|
||||||
|
assert.equal(resolveRulerUnit("a4"), "cm")
|
||||||
|
assert.equal(resolveRulerUnit("a5"), "cm")
|
||||||
|
assert.equal(resolveRulerUnit("letter"), "inch")
|
||||||
|
assert.equal(resolveRulerUnit("legal"), "inch")
|
||||||
|
assert.equal(resolveRulerUnit("tabloid"), "inch")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds cm ticks for A4 width", () => {
|
||||||
|
const widthPx = Math.round(210 * MM_TO_PX)
|
||||||
|
const ticks = buildHorizontalRulerTicks(widthPx, "a4")
|
||||||
|
const majors = ticks.filter((t) => t.major && t.label)
|
||||||
|
assert.equal(majors[0]?.label, "1")
|
||||||
|
assert.equal(majors.at(-1)?.label, "21")
|
||||||
|
assert.equal(getRulerUnitConfig("a4").majorStepPx, MM_TO_PX * 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds inch ticks for Letter width", () => {
|
||||||
|
const widthPx = Math.round(216 * MM_TO_PX)
|
||||||
|
const ticks = buildHorizontalRulerTicks(widthPx, "letter")
|
||||||
|
const majors = ticks.filter((t) => t.major && t.label)
|
||||||
|
assert.equal(majors[0]?.label, "1")
|
||||||
|
assert.equal(majors.length, 8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("formats margin tooltip in cm for A4", () => {
|
||||||
|
const cmStep = MM_TO_PX * 10
|
||||||
|
assert.equal(formatMarginDistanceLabel(cmStep * 2.5, "a4"), "2,5 cm")
|
||||||
|
assert.equal(formatMarginDistanceLabel(cmStep * 2, "a4"), "2 cm")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("formats margin tooltip in inches for Letter", () => {
|
||||||
|
const inchPx = MM_TO_PX * 25.4
|
||||||
|
assert.equal(formatMarginDistanceLabel(inchPx * 1.5, "letter"), '1.5"')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -6,11 +6,22 @@ import {
|
|||||||
type PageFormatId,
|
type PageFormatId,
|
||||||
} from "@/lib/drive/page-formats"
|
} from "@/lib/drive/page-formats"
|
||||||
|
|
||||||
|
export type DocsEditorMode = "edit" | "suggest" | "view"
|
||||||
|
|
||||||
|
export type DocsCommentsDisplay = "hidden" | "collapsed" | "expanded" | "all"
|
||||||
|
|
||||||
export type DocsViewSettings = {
|
export type DocsViewSettings = {
|
||||||
pageFormatId: PageFormatId
|
pageFormatId: PageFormatId
|
||||||
zoom: number
|
zoom: number
|
||||||
spellcheck: boolean
|
spellcheck: boolean
|
||||||
chromeCollapsed: boolean
|
chromeCollapsed: boolean
|
||||||
|
editorMode: DocsEditorMode
|
||||||
|
commentsDisplay: DocsCommentsDisplay
|
||||||
|
outlineSidebarExpanded: boolean
|
||||||
|
showLayout: boolean
|
||||||
|
showRuler: boolean
|
||||||
|
showEquationToolbar: boolean
|
||||||
|
showNonPrintableChars: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = "ultidrive-docs-view-settings"
|
const STORAGE_KEY = "ultidrive-docs-view-settings"
|
||||||
@ -20,6 +31,13 @@ const DEFAULT_SETTINGS: DocsViewSettings = {
|
|||||||
zoom: 100,
|
zoom: 100,
|
||||||
spellcheck: true,
|
spellcheck: true,
|
||||||
chromeCollapsed: false,
|
chromeCollapsed: false,
|
||||||
|
editorMode: "edit",
|
||||||
|
commentsDisplay: "expanded",
|
||||||
|
outlineSidebarExpanded: false,
|
||||||
|
showLayout: true,
|
||||||
|
showRuler: true,
|
||||||
|
showEquationToolbar: false,
|
||||||
|
showNonPrintableChars: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ZOOM_MIN = 50
|
const ZOOM_MIN = 50
|
||||||
@ -41,6 +59,16 @@ function readSettings(): DocsViewSettings {
|
|||||||
zoom: clampZoom(parsed.zoom ?? DEFAULT_SETTINGS.zoom),
|
zoom: clampZoom(parsed.zoom ?? DEFAULT_SETTINGS.zoom),
|
||||||
spellcheck: parsed.spellcheck ?? DEFAULT_SETTINGS.spellcheck,
|
spellcheck: parsed.spellcheck ?? DEFAULT_SETTINGS.spellcheck,
|
||||||
chromeCollapsed: parsed.chromeCollapsed ?? DEFAULT_SETTINGS.chromeCollapsed,
|
chromeCollapsed: parsed.chromeCollapsed ?? DEFAULT_SETTINGS.chromeCollapsed,
|
||||||
|
editorMode: parsed.editorMode ?? DEFAULT_SETTINGS.editorMode,
|
||||||
|
commentsDisplay: parsed.commentsDisplay ?? DEFAULT_SETTINGS.commentsDisplay,
|
||||||
|
outlineSidebarExpanded:
|
||||||
|
parsed.outlineSidebarExpanded ?? DEFAULT_SETTINGS.outlineSidebarExpanded,
|
||||||
|
showLayout: parsed.showLayout ?? DEFAULT_SETTINGS.showLayout,
|
||||||
|
showRuler: parsed.showRuler ?? DEFAULT_SETTINGS.showRuler,
|
||||||
|
showEquationToolbar:
|
||||||
|
parsed.showEquationToolbar ?? DEFAULT_SETTINGS.showEquationToolbar,
|
||||||
|
showNonPrintableChars:
|
||||||
|
parsed.showNonPrintableChars ?? DEFAULT_SETTINGS.showNonPrintableChars,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_SETTINGS
|
return DEFAULT_SETTINGS
|
||||||
@ -99,6 +127,62 @@ export function useDocsViewSettings() {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const setEditorMode = useCallback((editorMode: DocsEditorMode) => {
|
||||||
|
setSettings((prev) => {
|
||||||
|
const next = { ...prev, editorMode }
|
||||||
|
writeSettings(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setCommentsDisplay = useCallback((commentsDisplay: DocsCommentsDisplay) => {
|
||||||
|
setSettings((prev) => {
|
||||||
|
const next = { ...prev, commentsDisplay }
|
||||||
|
writeSettings(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleOutlineSidebarExpanded = useCallback(() => {
|
||||||
|
setSettings((prev) => {
|
||||||
|
const next = { ...prev, outlineSidebarExpanded: !prev.outlineSidebarExpanded }
|
||||||
|
writeSettings(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleShowLayout = useCallback(() => {
|
||||||
|
setSettings((prev) => {
|
||||||
|
const next = { ...prev, showLayout: !prev.showLayout }
|
||||||
|
writeSettings(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleShowRuler = useCallback(() => {
|
||||||
|
setSettings((prev) => {
|
||||||
|
const next = { ...prev, showRuler: !prev.showRuler }
|
||||||
|
writeSettings(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleShowEquationToolbar = useCallback(() => {
|
||||||
|
setSettings((prev) => {
|
||||||
|
const next = { ...prev, showEquationToolbar: !prev.showEquationToolbar }
|
||||||
|
writeSettings(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleShowNonPrintableChars = useCallback(() => {
|
||||||
|
setSettings((prev) => {
|
||||||
|
const next = { ...prev, showNonPrintableChars: !prev.showNonPrintableChars }
|
||||||
|
writeSettings(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
settings,
|
settings,
|
||||||
setPageFormatId,
|
setPageFormatId,
|
||||||
@ -106,6 +190,13 @@ export function useDocsViewSettings() {
|
|||||||
setSpellcheck,
|
setSpellcheck,
|
||||||
toggleSpellcheck,
|
toggleSpellcheck,
|
||||||
toggleChromeCollapsed,
|
toggleChromeCollapsed,
|
||||||
|
setEditorMode,
|
||||||
|
setCommentsDisplay,
|
||||||
|
toggleOutlineSidebarExpanded,
|
||||||
|
toggleShowLayout,
|
||||||
|
toggleShowRuler,
|
||||||
|
toggleShowEquationToolbar,
|
||||||
|
toggleShowNonPrintableChars,
|
||||||
zoomMin: ZOOM_MIN,
|
zoomMin: ZOOM_MIN,
|
||||||
zoomMax: ZOOM_MAX,
|
zoomMax: ZOOM_MAX,
|
||||||
}
|
}
|
||||||
|
|||||||
326
lib/drive/docx-drawing-import.ts
Normal file
326
lib/drive/docx-drawing-import.ts
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
import type { TipTapJSON } from "@/lib/drive/richtext-import"
|
||||||
|
import {
|
||||||
|
buildGradientCss,
|
||||||
|
DOCS_GRAPHIC_DEFAULTS,
|
||||||
|
type DocsShapeType,
|
||||||
|
} from "./docs-graphic-types.ts"
|
||||||
|
import {
|
||||||
|
parseVmlColorForDrawing,
|
||||||
|
parseRotationDegFromStyle,
|
||||||
|
} from "./docx-drawing-vml.ts"
|
||||||
|
import { resolveDocxMediaDataUrl } from "./doc-page-background.ts"
|
||||||
|
|
||||||
|
type DocxArchive = Record<string, Uint8Array>
|
||||||
|
|
||||||
|
export type DocxBodyDrawing = {
|
||||||
|
graphicType: "image" | "shape" | "gradient"
|
||||||
|
src?: string
|
||||||
|
shapeType?: DocsShapeType
|
||||||
|
fill?: string
|
||||||
|
stroke?: string
|
||||||
|
strokeWidth?: number
|
||||||
|
gradientCss?: string
|
||||||
|
gradientAngle?: number
|
||||||
|
gradientColor1?: string
|
||||||
|
gradientColor2?: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
rotationDeg: number
|
||||||
|
wrap: "square" | "behind" | "in-front" | "inline"
|
||||||
|
placement: "block" | "absolute" | "inline"
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMU_PER_PX = 914400 / 96
|
||||||
|
|
||||||
|
function decodeXml(bytes: Uint8Array | undefined): string {
|
||||||
|
if (!bytes) return ""
|
||||||
|
return new TextDecoder().decode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
function emuToPx(emu: number): number {
|
||||||
|
return Math.round(emu / EMU_PER_PX)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIntAttr(fragment: string, name: string): number | null {
|
||||||
|
const match = fragment.match(new RegExp(`\\b${name}="(-?\\d+)"`, "i"))
|
||||||
|
if (!match) return null
|
||||||
|
const value = Number.parseInt(match[1] ?? "", 10)
|
||||||
|
return Number.isFinite(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePresetShape(prst: string | undefined): DocsShapeType {
|
||||||
|
switch (prst) {
|
||||||
|
case "ellipse":
|
||||||
|
case "oval":
|
||||||
|
return "ellipse"
|
||||||
|
case "line":
|
||||||
|
case "straightConnector1":
|
||||||
|
return "line"
|
||||||
|
case "rightArrow":
|
||||||
|
case "leftArrow":
|
||||||
|
case "bentArrow":
|
||||||
|
return "arrow"
|
||||||
|
default:
|
||||||
|
return "rect"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDrawingXml(
|
||||||
|
block: string,
|
||||||
|
archive: DocxArchive,
|
||||||
|
relsPath: string
|
||||||
|
): DocxBodyDrawing | null {
|
||||||
|
const extent = block.match(/<wp:extent\b[^>]*\/?>/i)?.[0]
|
||||||
|
const cx = extent ? readIntAttr(extent, "cx") : null
|
||||||
|
const cy = extent ? readIntAttr(extent, "cy") : null
|
||||||
|
const width = cx != null ? Math.max(24, emuToPx(cx)) : 240
|
||||||
|
const height = cy != null ? Math.max(24, emuToPx(cy)) : 160
|
||||||
|
|
||||||
|
let x = 0
|
||||||
|
let y = 0
|
||||||
|
const posH = block.match(/<wp:positionH\b[^>]*>[\s\S]*?<\/wp:positionH>/i)?.[0]
|
||||||
|
const posV = block.match(/<wp:positionV\b[^>]*>[\s\S]*?<\/wp:positionV>/i)?.[0]
|
||||||
|
const posOffsetH = posH?.match(/<wp:posOffset>(-?\d+)<\/wp:posOffset>/i)?.[1]
|
||||||
|
const posOffsetV = posV?.match(/<wp:posOffset>(-?\d+)<\/wp:posOffset>/i)?.[1]
|
||||||
|
if (posOffsetH) x = emuToPx(Number(posOffsetH))
|
||||||
|
if (posOffsetV) y = emuToPx(Number(posOffsetV))
|
||||||
|
|
||||||
|
const rot = block.match(/\brot="(-?\d+)"/i)?.[1]
|
||||||
|
const rotationDeg = rot ? Math.round(Number(rot) / 60000) : 0
|
||||||
|
|
||||||
|
const behindDoc = /<wp:anchor\b[^>]*\bbehindDoc="1"/i.test(block)
|
||||||
|
const inline = /<wp:inline\b/i.test(block)
|
||||||
|
const wrap = inline ? "inline" : behindDoc ? "behind" : "square"
|
||||||
|
const placement = inline ? "inline" : "absolute"
|
||||||
|
|
||||||
|
const blipEmbed = block.match(/<a:blip\b[^>]*\bembed="([^"]+)"/i)?.[1]
|
||||||
|
if (blipEmbed) {
|
||||||
|
const src = resolveDocxMediaDataUrl(archive, relsPath, blipEmbed)
|
||||||
|
if (src) {
|
||||||
|
return {
|
||||||
|
graphicType: "image",
|
||||||
|
src,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
rotationDeg,
|
||||||
|
wrap,
|
||||||
|
placement,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prstGeom = block.match(/<a:prstGeom\b[^>]*\bprst="([^"]+)"/i)?.[1]
|
||||||
|
const solidFill = block.match(/<a:solidFill\b[^>]*>[\s\S]*?<\/a:solidFill>/i)?.[0]
|
||||||
|
const gradFill = block.match(/<a:gradFill\b[^>]*>[\s\S]*?<\/a:gradFill>/i)?.[0]
|
||||||
|
const ln = block.match(/<a:ln\b[^>]*>[\s\S]*?<\/a:ln>/i)?.[0] ?? block.match(/<a:ln\b[^>]*\/?>/i)?.[0]
|
||||||
|
|
||||||
|
if (gradFill) {
|
||||||
|
const stop1 = gradFill.match(/<a:srgbClr\b[^>]*\bval="([^"]+)"/i)?.[1]
|
||||||
|
const stop2 = [...gradFill.matchAll(/<a:srgbClr\b[^>]*\bval="([^"]+)"/gi)][1]?.[1]
|
||||||
|
const color1 = stop1 ? `#${stop1}` : "#4285f4"
|
||||||
|
const color2 = stop2 ? `#${stop2}` : "#34a853"
|
||||||
|
const angle = readIntAttr(gradFill, "ang") ?? 1800000
|
||||||
|
const gradientAngle = Math.round(angle / 60000)
|
||||||
|
return {
|
||||||
|
graphicType: "gradient",
|
||||||
|
gradientCss: buildGradientCss(gradientAngle, color1, color2),
|
||||||
|
gradientAngle,
|
||||||
|
gradientColor1: color1,
|
||||||
|
gradientColor2: color2,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
rotationDeg,
|
||||||
|
wrap,
|
||||||
|
placement,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prstGeom || solidFill) {
|
||||||
|
const srgb = solidFill?.match(/<a:srgbClr\b[^>]*\bval="([^"]+)"/i)?.[1]
|
||||||
|
const fill = srgb ? `#${srgb}` : "#4285f4"
|
||||||
|
const strokeClr = ln?.match(/<a:srgbClr\b[^>]*\bval="([^"]+)"/i)?.[1]
|
||||||
|
const stroke = strokeClr ? `#${strokeClr}` : "#1a73e8"
|
||||||
|
const strokeWidth = ln ? Math.max(1, emuToPx(readIntAttr(ln, "w") ?? 12700)) : 2
|
||||||
|
return {
|
||||||
|
graphicType: "shape",
|
||||||
|
shapeType: parsePresetShape(prstGeom),
|
||||||
|
fill,
|
||||||
|
stroke,
|
||||||
|
strokeWidth,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
rotationDeg,
|
||||||
|
wrap,
|
||||||
|
placement,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVmlShape(
|
||||||
|
shapeXml: string,
|
||||||
|
archive: DocxArchive,
|
||||||
|
relsPath: string
|
||||||
|
): DocxBodyDrawing | null {
|
||||||
|
const style = shapeXml.match(/\bstyle="([^"]+)"/i)?.[1] ?? ""
|
||||||
|
const widthMatch = style.match(/\bwidth:\s*([\d.]+)pt/i)
|
||||||
|
const heightMatch = style.match(/\bheight:\s*([\d.]+)pt/i)
|
||||||
|
const width = widthMatch ? Math.round(Number(widthMatch[1]) * 96 / 72) : 240
|
||||||
|
const height = heightMatch ? Math.round(Number(heightMatch[1]) * 96 / 72) : 160
|
||||||
|
|
||||||
|
const leftMatch = style.match(/\bleft:\s*([\d.]+)pt/i)
|
||||||
|
const topMatch = style.match(/\btop:\s*([\d.]+)pt/i)
|
||||||
|
const x = leftMatch ? Math.round(Number(leftMatch[1]) * 96 / 72) : 0
|
||||||
|
const y = topMatch ? Math.round(Number(topMatch[1]) * 96 / 72) : 0
|
||||||
|
|
||||||
|
const fill = parseVmlColorForDrawing(shapeXml.match(/\bfillcolor="([^"]+)"/i)?.[1])
|
||||||
|
const stroke = parseVmlColorForDrawing(shapeXml.match(/\bstrokecolor="([^"]+)"/i)?.[1])
|
||||||
|
const rotationDeg = parseRotationDegFromStyle(style)
|
||||||
|
|
||||||
|
const imagedata = shapeXml.match(/<v:imagedata\b[^>]*\br:id="([^"]+)"/i)?.[1]
|
||||||
|
if (imagedata) {
|
||||||
|
const src = resolveDocxMediaDataUrl(archive, relsPath, imagedata)
|
||||||
|
if (src) {
|
||||||
|
return {
|
||||||
|
graphicType: "image",
|
||||||
|
src,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
rotationDeg,
|
||||||
|
wrap: "square",
|
||||||
|
placement: "absolute",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = shapeXml.match(/\btype="[^"]*#([^"]+)"/i)?.[1]?.toLowerCase()
|
||||||
|
let shapeType: DocsShapeType = "rect"
|
||||||
|
if (type?.includes("oval") || type?.includes("ellipse")) shapeType = "ellipse"
|
||||||
|
else if (type?.includes("line")) shapeType = "line"
|
||||||
|
|
||||||
|
if (fill || stroke) {
|
||||||
|
return {
|
||||||
|
graphicType: "shape",
|
||||||
|
shapeType,
|
||||||
|
fill: fill ?? "#4285f4",
|
||||||
|
stroke: stroke ?? "#1a73e8",
|
||||||
|
strokeWidth: 2,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
rotationDeg,
|
||||||
|
wrap: "square",
|
||||||
|
placement: "absolute",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractBodyDrawingsFromDocx(archive: DocxArchive): DocxBodyDrawing[] {
|
||||||
|
const documentXml = decodeXml(archive["word/document.xml"])
|
||||||
|
if (!documentXml) return []
|
||||||
|
|
||||||
|
const relsPath = "word/_rels/document.xml.rels"
|
||||||
|
const drawings: DocxBodyDrawing[] = []
|
||||||
|
|
||||||
|
for (const match of documentXml.matchAll(/<w:drawing\b[^>]*>[\s\S]*?<\/w:drawing>/gi)) {
|
||||||
|
const parsed = parseDrawingXml(match[0], archive, relsPath)
|
||||||
|
if (parsed?.graphicType !== "image") {
|
||||||
|
if (parsed) drawings.push(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of documentXml.matchAll(/<v:shape\b[^>]*>[\s\S]*?<\/v:shape>/gi)) {
|
||||||
|
const parsed = parseVmlShape(match[0], archive, relsPath)
|
||||||
|
if (parsed) drawings.push(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return drawings
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawingToGraphicNode(drawing: DocxBodyDrawing): TipTapJSON {
|
||||||
|
return {
|
||||||
|
type: "docsGraphic",
|
||||||
|
attrs: {
|
||||||
|
...DOCS_GRAPHIC_DEFAULTS,
|
||||||
|
graphicType: drawing.graphicType,
|
||||||
|
src: drawing.src ?? null,
|
||||||
|
shapeType: drawing.shapeType ?? "rect",
|
||||||
|
fill: drawing.fill ?? DOCS_GRAPHIC_DEFAULTS.fill,
|
||||||
|
stroke: drawing.stroke ?? DOCS_GRAPHIC_DEFAULTS.stroke,
|
||||||
|
strokeWidth: drawing.strokeWidth ?? 2,
|
||||||
|
gradientCss: drawing.gradientCss ?? "",
|
||||||
|
gradientAngle: drawing.gradientAngle ?? 180,
|
||||||
|
gradientColor1: drawing.gradientColor1 ?? DOCS_GRAPHIC_DEFAULTS.gradientColor1,
|
||||||
|
gradientColor2: drawing.gradientColor2 ?? DOCS_GRAPHIC_DEFAULTS.gradientColor2,
|
||||||
|
width: drawing.width,
|
||||||
|
height: drawing.height,
|
||||||
|
x: drawing.x,
|
||||||
|
y: drawing.y,
|
||||||
|
rotationDeg: drawing.rotationDeg,
|
||||||
|
wrap: drawing.wrap,
|
||||||
|
placement: drawing.placement,
|
||||||
|
floatSide: "left",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countGraphicImages(content: TipTapJSON): number {
|
||||||
|
let count = 0
|
||||||
|
const walk = (node: TipTapJSON) => {
|
||||||
|
if (
|
||||||
|
node.type === "image" ||
|
||||||
|
(node.type === "docsGraphic" &&
|
||||||
|
(node.attrs as { graphicType?: string })?.graphicType === "image")
|
||||||
|
) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
node.content.forEach((child) => {
|
||||||
|
if (child && typeof child === "object") walk(child as TipTapJSON)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(content)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert shape/gradient drawings not already represented as image nodes. */
|
||||||
|
export async function enrichContentFromDocxDrawings(
|
||||||
|
buffer: ArrayBuffer,
|
||||||
|
content: TipTapJSON
|
||||||
|
): Promise<TipTapJSON> {
|
||||||
|
try {
|
||||||
|
const { unzipSync } = await import("fflate")
|
||||||
|
const archive = unzipSync(new Uint8Array(buffer)) as DocxArchive
|
||||||
|
const drawings = extractBodyDrawingsFromDocx(archive)
|
||||||
|
const nonImageDrawings = drawings.filter((d) => d.graphicType !== "image")
|
||||||
|
if (nonImageDrawings.length === 0) return content
|
||||||
|
|
||||||
|
const existingImages = countGraphicImages(content)
|
||||||
|
const toInsert = nonImageDrawings.slice(Math.max(0, existingImages))
|
||||||
|
|
||||||
|
if (toInsert.length === 0) return content
|
||||||
|
|
||||||
|
const contentArray = Array.isArray(content.content) ? [...content.content] : []
|
||||||
|
for (const drawing of toInsert) {
|
||||||
|
contentArray.push(drawingToGraphicNode(drawing))
|
||||||
|
}
|
||||||
|
return { ...content, content: contentArray }
|
||||||
|
} catch {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
12
lib/drive/docx-drawing-vml.ts
Normal file
12
lib/drive/docx-drawing-vml.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export function parseVmlColorForDrawing(raw: string | undefined): string | undefined {
|
||||||
|
if (!raw) return undefined
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
if (trimmed.startsWith("#") && trimmed.length === 7) return trimmed
|
||||||
|
if (/^[0-9a-f]{6}$/i.test(trimmed)) return `#${trimmed}`
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRotationDegFromStyle(style: string): number {
|
||||||
|
const match = style.match(/\brotation:\s*(-?\d+)/i)
|
||||||
|
return match ? Number(match[1]) : 0
|
||||||
|
}
|
||||||
96
lib/drive/docx-header-footer-import.ts
Normal file
96
lib/drive/docx-header-footer-import.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import type { TipTapJSON } from "@/lib/drive/richtext-import"
|
||||||
|
import type { DocPageHeaderFooter, DocPageSetup } from "@/lib/drive/doc-page-setup"
|
||||||
|
|
||||||
|
type DocxArchive = Record<string, Uint8Array>
|
||||||
|
|
||||||
|
function decodeXml(bytes: Uint8Array | undefined): string {
|
||||||
|
if (!bytes) return ""
|
||||||
|
return new TextDecoder().decode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
function xmlTextToParagraphs(xml: string): TipTapJSON[] {
|
||||||
|
const paragraphs: TipTapJSON[] = []
|
||||||
|
const pBlocks = [...xml.matchAll(/<w:p\b[^>]*>[\s\S]*?<\/w:p>/gi)]
|
||||||
|
|
||||||
|
if (pBlocks.length === 0) {
|
||||||
|
const text = xml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()
|
||||||
|
if (text) {
|
||||||
|
paragraphs.push({ type: "paragraph", content: [{ type: "text", text }] })
|
||||||
|
}
|
||||||
|
return paragraphs
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pMatch of pBlocks) {
|
||||||
|
const pXml = pMatch[0]
|
||||||
|
const runs = [...pXml.matchAll(/<w:t\b[^>]*>([^<]*)<\/w:t>/gi)]
|
||||||
|
const text = runs.map((r) => r[1] ?? "").join("")
|
||||||
|
const content: TipTapJSON[] = []
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
const marks: TipTapJSON[] = []
|
||||||
|
if (/<w:b\b[^>]*\/>|<w:b\b[^>]*>/.test(pXml)) marks.push({ type: "bold" })
|
||||||
|
if (/<w:i\b[^>]*\/>|<w:i\b[^>]*>/.test(pXml)) marks.push({ type: "italic" })
|
||||||
|
if (/<w:u\b[^>]*\/>|<w:u\b[^>]*>/.test(pXml)) marks.push({ type: "underline" })
|
||||||
|
content.push({ type: "text", text, ...(marks.length ? { marks } : {}) })
|
||||||
|
}
|
||||||
|
|
||||||
|
paragraphs.push({ type: "paragraph", content })
|
||||||
|
}
|
||||||
|
|
||||||
|
return paragraphs
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHeaderFooterPart(
|
||||||
|
archive: DocxArchive,
|
||||||
|
partName: string
|
||||||
|
): DocPageHeaderFooter | null {
|
||||||
|
const xml = decodeXml(archive[`word/${partName}.xml`])
|
||||||
|
if (!xml) return null
|
||||||
|
|
||||||
|
const contentBlocks = xmlTextToParagraphs(xml)
|
||||||
|
if (contentBlocks.length === 0) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: { type: "doc", content: contentBlocks },
|
||||||
|
heightMm: 15,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocxHeaderFooterResult = Pick<
|
||||||
|
DocPageSetup,
|
||||||
|
"header" | "footer" | "headerFooterDifferentFirstPage"
|
||||||
|
>
|
||||||
|
|
||||||
|
/** Extract header/footer body content from DOCX archive. */
|
||||||
|
export async function extractDocxHeaderFooter(
|
||||||
|
buffer: ArrayBuffer
|
||||||
|
): Promise<DocxHeaderFooterResult> {
|
||||||
|
try {
|
||||||
|
const { unzipSync } = await import("fflate")
|
||||||
|
const archive = unzipSync(new Uint8Array(buffer)) as DocxArchive
|
||||||
|
|
||||||
|
const header =
|
||||||
|
parseHeaderFooterPart(archive, "header1") ??
|
||||||
|
parseHeaderFooterPart(archive, "header2")
|
||||||
|
const footer =
|
||||||
|
parseHeaderFooterPart(archive, "footer1") ??
|
||||||
|
parseHeaderFooterPart(archive, "footer2")
|
||||||
|
|
||||||
|
const documentXml = decodeXml(archive["word/document.xml"])
|
||||||
|
const differentFirst =
|
||||||
|
documentXml != null &&
|
||||||
|
/<w:titlePg\b/i.test(documentXml)
|
||||||
|
|
||||||
|
return {
|
||||||
|
header: header ?? null,
|
||||||
|
footer: footer ?? null,
|
||||||
|
headerFooterDifferentFirstPage: differentFirst,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
header: null,
|
||||||
|
footer: null,
|
||||||
|
headerFooterDifferentFirstPage: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
223
lib/drive/docx-position-import.ts
Normal file
223
lib/drive/docx-position-import.ts
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
import type { TipTapJSON } from "@/lib/drive/richtext-import"
|
||||||
|
import type { DocsGraphicFloatSide, DocsGraphicPlacement, DocsGraphicWrap } from "./docs-graphic-types.ts"
|
||||||
|
|
||||||
|
type DocxArchive = Record<string, Uint8Array>
|
||||||
|
|
||||||
|
const EMU_PER_PX = 914400 / 96
|
||||||
|
|
||||||
|
export type DocxGraphicPosition = {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
placement: DocsGraphicPlacement
|
||||||
|
wrap: DocsGraphicWrap
|
||||||
|
floatSide: DocsGraphicFloatSide
|
||||||
|
rotationDeg: number
|
||||||
|
zIndex: number
|
||||||
|
behindDoc: boolean
|
||||||
|
cropX?: number
|
||||||
|
cropY?: number
|
||||||
|
cropWidth?: number
|
||||||
|
cropHeight?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeXml(bytes: Uint8Array | undefined): string {
|
||||||
|
if (!bytes) return ""
|
||||||
|
return new TextDecoder().decode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
function emuToPx(emu: number): number {
|
||||||
|
return Math.round(emu / EMU_PER_PX)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIntAttr(fragment: string, name: string): number | null {
|
||||||
|
const match = fragment.match(new RegExp(`\\b${name}="(-?\\d+)"`, "i"))
|
||||||
|
if (!match) return null
|
||||||
|
const value = Number.parseInt(match[1] ?? "", 10)
|
||||||
|
return Number.isFinite(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWrapType(anchorXml: string): DocsGraphicWrap {
|
||||||
|
if (/<wp:wrapNone\b/i.test(anchorXml)) return "top-bottom"
|
||||||
|
if (/<wp:wrapTopAndBottom\b/i.test(anchorXml)) return "top-bottom"
|
||||||
|
if (/<wp:wrapTight\b/i.test(anchorXml)) return "tight"
|
||||||
|
if (/<wp:wrapThrough\b/i.test(anchorXml)) return "through"
|
||||||
|
if (/<wp:wrapSquare\b/i.test(anchorXml)) return "square"
|
||||||
|
if (/<wp:wrapNone\b/i.test(anchorXml)) return "square"
|
||||||
|
return "square"
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFloatSide(anchorXml: string): DocsGraphicFloatSide {
|
||||||
|
const align = anchorXml.match(/<wp:positionH\b[^>]*>[\s\S]*?<wp:align>(\w+)<\/wp:align>/i)?.[1]
|
||||||
|
if (align === "right") return "right"
|
||||||
|
if (align === "center") return "center"
|
||||||
|
return "left"
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRotation(drawingXml: string): number {
|
||||||
|
const rot = drawingXml.match(/\brot="(-?\d+)"/i)?.[1]
|
||||||
|
if (!rot) return 0
|
||||||
|
// OOXML rotation is in 60000ths of a degree
|
||||||
|
return Math.round(Number(rot) / 60000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCropFromBlip(blipXml: string): Pick<
|
||||||
|
DocxGraphicPosition,
|
||||||
|
"cropX" | "cropY" | "cropWidth" | "cropHeight"
|
||||||
|
> | null {
|
||||||
|
const srcRect = blipXml.match(/<a:srcRect\b[^>]*\/?>/i)?.[0]
|
||||||
|
if (!srcRect) return null
|
||||||
|
const l = readIntAttr(srcRect, "l") ?? 0
|
||||||
|
const t = readIntAttr(srcRect, "t") ?? 0
|
||||||
|
const r = readIntAttr(srcRect, "r") ?? 0
|
||||||
|
const b = readIntAttr(srcRect, "b") ?? 0
|
||||||
|
if (l === 0 && t === 0 && r === 0 && b === 0) return null
|
||||||
|
const cropX = l / 100000
|
||||||
|
const cropY = t / 100000
|
||||||
|
const cropWidth = 1 - (l + r) / 100000
|
||||||
|
const cropHeight = 1 - (t + b) / 100000
|
||||||
|
return { cropX, cropY, cropWidth, cropHeight }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDrawingBlock(block: string, inline: boolean): DocxGraphicPosition | null {
|
||||||
|
const extent = block.match(/<wp:extent\b[^>]*\/?>/i)?.[0]
|
||||||
|
if (!extent) return null
|
||||||
|
const cx = readIntAttr(extent, "cx")
|
||||||
|
const cy = readIntAttr(extent, "cy")
|
||||||
|
if (cx == null || cy == null) return null
|
||||||
|
|
||||||
|
const behindDoc = /<wp:anchor\b[^>]*\bbehindDoc="1"/i.test(block)
|
||||||
|
const inFront = /<wp:anchor\b[^>]*\blayoutInCell="0"/i.test(block) && !behindDoc
|
||||||
|
|
||||||
|
let x = 0
|
||||||
|
let y = 0
|
||||||
|
if (!inline) {
|
||||||
|
const posH = block.match(/<wp:positionH\b[^>]*>[\s\S]*?<\/wp:positionH>/i)?.[0]
|
||||||
|
const posV = block.match(/<wp:positionV\b[^>]*>[\s\S]*?<\/wp:positionV>/i)?.[0]
|
||||||
|
const posOffsetH = posH?.match(/<wp:posOffset>(-?\d+)<\/wp:posOffset>/i)?.[1]
|
||||||
|
const posOffsetV = posV?.match(/<wp:posOffset>(-?\d+)<\/wp:posOffset>/i)?.[1]
|
||||||
|
if (posOffsetH) x = emuToPx(Number(posOffsetH))
|
||||||
|
if (posOffsetV) y = emuToPx(Number(posOffsetV))
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrap = inline ? "inline" : behindDoc ? "behind" : inFront ? "in-front" : parseWrapType(block)
|
||||||
|
const placement: DocsGraphicPlacement = inline ? "inline" : "absolute"
|
||||||
|
|
||||||
|
const blip = block.match(/<a:blip\b[^>]*\/?>/i)?.[0] ?? ""
|
||||||
|
const crop = parseCropFromBlip(blip)
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: Math.max(24, emuToPx(cx)),
|
||||||
|
height: Math.max(24, emuToPx(cy)),
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
placement,
|
||||||
|
wrap,
|
||||||
|
floatSide: inline ? "left" : parseFloatSide(block),
|
||||||
|
rotationDeg: parseRotation(block),
|
||||||
|
zIndex: behindDoc ? 0 : inFront ? 20 : 1,
|
||||||
|
behindDoc,
|
||||||
|
...crop,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract ordered graphic positions from word/document.xml. */
|
||||||
|
export function extractGraphicPositionsFromDocx(archive: DocxArchive): DocxGraphicPosition[] {
|
||||||
|
const documentXml = decodeXml(archive["word/document.xml"])
|
||||||
|
if (!documentXml) return []
|
||||||
|
|
||||||
|
const positions: DocxGraphicPosition[] = []
|
||||||
|
const drawingPattern = /<w:drawing\b[^>]*>[\s\S]*?<\/w:drawing>/gi
|
||||||
|
for (const match of documentXml.matchAll(drawingPattern)) {
|
||||||
|
const block = match[0]
|
||||||
|
const inline = /<wp:inline\b/i.test(block)
|
||||||
|
const anchor = /<wp:anchor\b/i.test(block)
|
||||||
|
if (!inline && !anchor) continue
|
||||||
|
const parsed = parseDrawingBlock(block, inline)
|
||||||
|
if (parsed) positions.push(parsed)
|
||||||
|
}
|
||||||
|
return positions
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGraphicNode(node: TipTapJSON): boolean {
|
||||||
|
return (
|
||||||
|
node.type === "image" ||
|
||||||
|
node.type === "docsGraphic" ||
|
||||||
|
node.type === "docsInlineGraphic"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPositionToNode(node: TipTapJSON, pos: DocxGraphicPosition): TipTapJSON {
|
||||||
|
const attrs = (node.attrs as Record<string, unknown>) ?? {}
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
attrs: {
|
||||||
|
...attrs,
|
||||||
|
width: pos.width,
|
||||||
|
height: pos.height,
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
placement: pos.placement,
|
||||||
|
wrap: pos.wrap,
|
||||||
|
floatSide: pos.floatSide,
|
||||||
|
rotationDeg: pos.rotationDeg,
|
||||||
|
zIndex: pos.zIndex,
|
||||||
|
...(pos.cropX != null ? { cropX: pos.cropX } : {}),
|
||||||
|
...(pos.cropY != null ? { cropY: pos.cropY } : {}),
|
||||||
|
...(pos.cropWidth != null ? { cropWidth: pos.cropWidth } : {}),
|
||||||
|
...(pos.cropHeight != null ? { cropHeight: pos.cropHeight } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectGraphicNodes(content: TipTapJSON): { path: number[]; node: TipTapJSON }[] {
|
||||||
|
const results: { path: number[]; node: TipTapJSON }[] = []
|
||||||
|
|
||||||
|
const walk = (node: TipTapJSON, path: number[]) => {
|
||||||
|
if (isGraphicNode(node)) {
|
||||||
|
results.push({ path, node })
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
node.content.forEach((child, index) => {
|
||||||
|
if (child && typeof child === "object") walk(child as TipTapJSON, [...path, index])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(content, [])
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNodeAtPath(root: TipTapJSON, path: number[], node: TipTapJSON): TipTapJSON {
|
||||||
|
if (path.length === 0) return node
|
||||||
|
const [head, ...rest] = path
|
||||||
|
const content = Array.isArray(root.content) ? [...root.content] : []
|
||||||
|
content[head!] = setNodeAtPath(content[head!] as TipTapJSON, rest, node)
|
||||||
|
return { ...root, content }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enrich imported TipTap content with OOXML anchor/inline positioning. */
|
||||||
|
export async function enrichGraphicsFromDocxPositions(
|
||||||
|
buffer: ArrayBuffer,
|
||||||
|
content: TipTapJSON
|
||||||
|
): Promise<TipTapJSON> {
|
||||||
|
try {
|
||||||
|
const { unzipSync } = await import("fflate")
|
||||||
|
const archive = unzipSync(new Uint8Array(buffer)) as DocxArchive
|
||||||
|
const positions = extractGraphicPositionsFromDocx(archive)
|
||||||
|
if (positions.length === 0) return content
|
||||||
|
|
||||||
|
const graphics = collectGraphicNodes(content)
|
||||||
|
let result = content
|
||||||
|
const count = Math.min(graphics.length, positions.length)
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const { path, node } = graphics[i]!
|
||||||
|
const pos = positions[i]!
|
||||||
|
result = setNodeAtPath(result, path, applyPositionToNode(node, pos))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,20 +6,20 @@ export const DRIVE_DIALOG_OVERLAY =
|
|||||||
"bg-[#3c4043]/40 backdrop-blur-[2px] dark:bg-[#202124]/60"
|
"bg-[#3c4043]/40 backdrop-blur-[2px] dark:bg-[#202124]/60"
|
||||||
|
|
||||||
export const DRIVE_DIALOG_CONTENT = cn(
|
export const DRIVE_DIALOG_CONTENT = cn(
|
||||||
"gap-0 overflow-hidden border-[#e8eaed] bg-white p-0 shadow-xl dark:border-[#5f6368]/30 dark:bg-[#292a2d]"
|
"drive-dialog gap-0 overflow-hidden border-[#e8eaed] bg-white p-0 shadow-xl dark:border-[#3c4043] dark:bg-[#292a2d]"
|
||||||
)
|
)
|
||||||
|
|
||||||
export const DRIVE_DIALOG_HEADER = cn(
|
export const DRIVE_DIALOG_HEADER = cn(
|
||||||
"space-y-1 border-b border-[#e8eaed] px-6 py-5 text-left dark:border-[#5f6368]/30"
|
"space-y-1 border-b border-[#e8eaed] px-6 py-5 text-left dark:border-[#3c4043]"
|
||||||
)
|
)
|
||||||
|
|
||||||
export const DRIVE_DIALOG_BODY = "px-6 py-5"
|
export const DRIVE_DIALOG_BODY = "px-6 py-5"
|
||||||
|
|
||||||
export const DRIVE_DIALOG_FOOTER = cn(
|
export const DRIVE_DIALOG_FOOTER = cn(
|
||||||
"flex-row justify-end gap-2 border-t border-[#e8eaed] bg-[#f8f9fa] px-6 py-4 dark:border-[#5f6368]/30 dark:bg-[#35363a]/50"
|
"flex-row justify-end gap-2 border-t border-[#e8eaed] bg-[#f8f9fa] px-6 py-4 dark:border-[#3c4043] dark:bg-[#252628]"
|
||||||
)
|
)
|
||||||
|
|
||||||
export const DRIVE_DIALOG_DIVIDER = "border-[#e8eaed] dark:border-[#5f6368]/30"
|
export const DRIVE_DIALOG_DIVIDER = "border-[#e8eaed] dark:border-[#3c4043]"
|
||||||
|
|
||||||
export const DRIVE_FIELD_CLASS =
|
export const DRIVE_FIELD_CLASS =
|
||||||
"h-10 rounded-lg border border-[#dadce0] bg-[#f1f3f4] text-sm text-[#3c4043] shadow-none placeholder:text-[#80868b] focus-visible:border-[#1a73e8] focus-visible:ring-2 focus-visible:ring-[#1a73e8]/20 dark:border-[#5f6368]/40 dark:bg-[#35363a] dark:text-[#e8eaed] dark:placeholder:text-[#9aa0a6] dark:focus-visible:border-[#8ab4f8] dark:focus-visible:ring-[#8ab4f8]/25"
|
"h-10 rounded-lg border border-[#dadce0] bg-[#f1f3f4] text-sm text-[#3c4043] shadow-none placeholder:text-[#80868b] focus-visible:border-[#1a73e8] focus-visible:ring-2 focus-visible:ring-[#1a73e8]/20 dark:border-[#5f6368]/40 dark:bg-[#35363a] dark:text-[#e8eaed] dark:placeholder:text-[#9aa0a6] dark:focus-visible:border-[#8ab4f8] dark:focus-visible:ring-[#8ab4f8]/25"
|
||||||
@ -34,7 +34,7 @@ export const DRIVE_TEXT_SECONDARY = "text-[#5f6368] dark:text-[#9aa0a6]"
|
|||||||
export const DRIVE_TEXT_TITLE = "text-[#202124] dark:text-[#e8eaed]"
|
export const DRIVE_TEXT_TITLE = "text-[#202124] dark:text-[#e8eaed]"
|
||||||
|
|
||||||
export const DRIVE_PANEL_MUTED = cn(
|
export const DRIVE_PANEL_MUTED = cn(
|
||||||
"rounded-xl border border-[#e8eaed] bg-[#f8f9fa] dark:border-[#5f6368]/30 dark:bg-[#35363a]/70"
|
"rounded-xl border border-[#e8eaed] bg-[#f8f9fa] dark:border-[#3c4043] dark:bg-[#35363a]"
|
||||||
)
|
)
|
||||||
|
|
||||||
export const DRIVE_CARD_IDLE = cn(
|
export const DRIVE_CARD_IDLE = cn(
|
||||||
@ -56,5 +56,5 @@ export const DRIVE_BTN_PRIMARY =
|
|||||||
export const DRIVE_SHEET_OVERLAY = "z-[100] bg-[#3c4043]/40 backdrop-blur-[2px] dark:bg-[#202124]/60"
|
export const DRIVE_SHEET_OVERLAY = "z-[100] bg-[#3c4043]/40 backdrop-blur-[2px] dark:bg-[#202124]/60"
|
||||||
|
|
||||||
export const DRIVE_SHEET_CONTENT = cn(
|
export const DRIVE_SHEET_CONTENT = cn(
|
||||||
"z-[100] rounded-t-2xl border-t border-[#e8eaed] bg-white p-0 pb-[env(safe-area-inset-bottom)] dark:border-[#5f6368]/30 dark:bg-[#292a2d]"
|
"drive-dialog z-[100] rounded-t-2xl border-t border-[#e8eaed] bg-white p-0 pb-[env(safe-area-inset-bottom)] dark:border-[#3c4043] dark:bg-[#292a2d]"
|
||||||
)
|
)
|
||||||
|
|||||||
71
lib/drive/extensions/docs-graphic-paste-drop.ts
Normal file
71
lib/drive/extensions/docs-graphic-paste-drop.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Extension } from "@tiptap/core"
|
||||||
|
import { Plugin, PluginKey } from "@tiptap/pm/state"
|
||||||
|
import { buildInsertGraphicAttrs } from "@/lib/drive/extensions/docs-graphic"
|
||||||
|
|
||||||
|
function readImageFile(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(reader.result as string)
|
||||||
|
reader.onerror = reject
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DocsGraphicPasteDrop = Extension.create({
|
||||||
|
name: "docsGraphicPasteDrop",
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
const editor = this.editor
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: new PluginKey("docsGraphicPasteDrop"),
|
||||||
|
props: {
|
||||||
|
handlePaste(view, event) {
|
||||||
|
const items = event.clipboardData?.items
|
||||||
|
if (!items) return false
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.type.startsWith("image/")) continue
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (!file) continue
|
||||||
|
event.preventDefault()
|
||||||
|
void readImageFile(file).then((src) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertDocsGraphic(
|
||||||
|
buildInsertGraphicAttrs("image", { src, width: 280, height: 180 })
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
handleDrop(view, event) {
|
||||||
|
const files = event.dataTransfer?.files
|
||||||
|
if (!files?.length) return false
|
||||||
|
const file = [...files].find((f) => f.type.startsWith("image/"))
|
||||||
|
if (!file) return false
|
||||||
|
event.preventDefault()
|
||||||
|
void readImageFile(file).then((src) => {
|
||||||
|
const coords = view.posAtCoords({
|
||||||
|
left: event.clientX,
|
||||||
|
top: event.clientY,
|
||||||
|
})
|
||||||
|
const chain = editor.chain().focus()
|
||||||
|
if (coords?.pos != null) chain.setTextSelection(coords.pos)
|
||||||
|
chain
|
||||||
|
.insertDocsGraphic(
|
||||||
|
buildInsertGraphicAttrs("image", { src, width: 280, height: 180 })
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
240
lib/drive/extensions/docs-graphic.ts
Normal file
240
lib/drive/extensions/docs-graphic.ts
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
import { mergeAttributes, Node } from "@tiptap/core"
|
||||||
|
import { ReactNodeViewRenderer } from "@tiptap/react"
|
||||||
|
import { DocsGraphicNodeView } from "@/components/drive/richtext/docs-graphic-node-view"
|
||||||
|
import {
|
||||||
|
buildGradientCss,
|
||||||
|
DOCS_GRAPHIC_DEFAULTS,
|
||||||
|
type DocsGraphicAttrs,
|
||||||
|
type DocsGraphicFloatSide,
|
||||||
|
type DocsGraphicPlacement,
|
||||||
|
type DocsGraphicType,
|
||||||
|
type DocsGraphicWrap,
|
||||||
|
type DocsShapeType,
|
||||||
|
parseGraphicAttrs,
|
||||||
|
} from "@/lib/drive/docs-graphic-types"
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
docsGraphic: {
|
||||||
|
insertDocsGraphic: (attrs: Partial<DocsGraphicAttrs>) => ReturnType
|
||||||
|
updateDocsGraphic: (attrs: Partial<DocsGraphicAttrs>) => ReturnType
|
||||||
|
setDocsGraphicWrap: (wrap: DocsGraphicWrap) => ReturnType
|
||||||
|
setDocsGraphicPlacement: (placement: DocsGraphicPlacement) => ReturnType
|
||||||
|
setDocsGraphicFloatSide: (floatSide: DocsGraphicFloatSide) => ReturnType
|
||||||
|
bringDocsGraphicForward: () => ReturnType
|
||||||
|
sendDocsGraphicBackward: () => ReturnType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const graphicAttributes = {
|
||||||
|
graphicType: { default: DOCS_GRAPHIC_DEFAULTS.graphicType },
|
||||||
|
src: { default: null as string | null },
|
||||||
|
alt: { default: "" },
|
||||||
|
shapeType: { default: DOCS_GRAPHIC_DEFAULTS.shapeType },
|
||||||
|
fill: { default: DOCS_GRAPHIC_DEFAULTS.fill },
|
||||||
|
stroke: { default: DOCS_GRAPHIC_DEFAULTS.stroke },
|
||||||
|
strokeWidth: { default: DOCS_GRAPHIC_DEFAULTS.strokeWidth },
|
||||||
|
gradientCss: { default: "" },
|
||||||
|
gradientAngle: { default: DOCS_GRAPHIC_DEFAULTS.gradientAngle },
|
||||||
|
gradientColor1: { default: DOCS_GRAPHIC_DEFAULTS.gradientColor1 },
|
||||||
|
gradientColor2: { default: DOCS_GRAPHIC_DEFAULTS.gradientColor2 },
|
||||||
|
width: { default: DOCS_GRAPHIC_DEFAULTS.width },
|
||||||
|
height: { default: DOCS_GRAPHIC_DEFAULTS.height },
|
||||||
|
placement: { default: DOCS_GRAPHIC_DEFAULTS.placement },
|
||||||
|
wrap: { default: DOCS_GRAPHIC_DEFAULTS.wrap },
|
||||||
|
floatSide: { default: DOCS_GRAPHIC_DEFAULTS.floatSide },
|
||||||
|
x: { default: 0 },
|
||||||
|
y: { default: 0 },
|
||||||
|
rotationDeg: { default: 0 },
|
||||||
|
zIndex: { default: 0 },
|
||||||
|
cropX: { default: 0 },
|
||||||
|
cropY: { default: 0 },
|
||||||
|
cropWidth: { default: 1 },
|
||||||
|
cropHeight: { default: 1 },
|
||||||
|
cropShape: { default: "rect" },
|
||||||
|
assetId: { default: null as string | null },
|
||||||
|
opacity: { default: 1 },
|
||||||
|
shadow: { default: "" },
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeGraphicAttrs(partial: Partial<DocsGraphicAttrs>): DocsGraphicAttrs {
|
||||||
|
const merged = parseGraphicAttrs({ ...DOCS_GRAPHIC_DEFAULTS, ...partial })
|
||||||
|
if (merged.graphicType === "gradient" && !partial.gradientCss) {
|
||||||
|
merged.gradientCss = buildGradientCss(
|
||||||
|
merged.gradientAngle,
|
||||||
|
merged.gradientColor1,
|
||||||
|
merged.gradientColor2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
function graphicCommands() {
|
||||||
|
return {
|
||||||
|
insertDocsGraphic:
|
||||||
|
(partial: Partial<DocsGraphicAttrs>) =>
|
||||||
|
({ chain }: { chain: () => { insertContent: (content: unknown) => { run: () => boolean } } }) => {
|
||||||
|
const attrs = mergeGraphicAttrs(partial)
|
||||||
|
const type =
|
||||||
|
attrs.placement === "inline" || attrs.wrap === "inline"
|
||||||
|
? "docsInlineGraphic"
|
||||||
|
: "docsGraphic"
|
||||||
|
return chain()
|
||||||
|
.insertContent({ type, attrs })
|
||||||
|
.run()
|
||||||
|
},
|
||||||
|
updateDocsGraphic:
|
||||||
|
(partial: Partial<DocsGraphicAttrs>) =>
|
||||||
|
({
|
||||||
|
chain,
|
||||||
|
editor,
|
||||||
|
}: {
|
||||||
|
chain: () => {
|
||||||
|
updateAttributes: (name: string, attrs: Partial<DocsGraphicAttrs>) => { run: () => boolean }
|
||||||
|
}
|
||||||
|
editor: { isActive: (name: string) => boolean }
|
||||||
|
}) => {
|
||||||
|
const name = editor.isActive("docsInlineGraphic") ? "docsInlineGraphic" : "docsGraphic"
|
||||||
|
return chain().updateAttributes(name, partial).run()
|
||||||
|
},
|
||||||
|
setDocsGraphicWrap:
|
||||||
|
(wrap: DocsGraphicWrap) =>
|
||||||
|
({
|
||||||
|
chain,
|
||||||
|
editor,
|
||||||
|
}: {
|
||||||
|
chain: () => {
|
||||||
|
updateAttributes: (name: string, attrs: { wrap: DocsGraphicWrap }) => { run: () => boolean }
|
||||||
|
}
|
||||||
|
editor: { isActive: (name: string) => boolean }
|
||||||
|
}) => {
|
||||||
|
const name = editor.isActive("docsInlineGraphic") ? "docsInlineGraphic" : "docsGraphic"
|
||||||
|
return chain().updateAttributes(name, { wrap }).run()
|
||||||
|
},
|
||||||
|
setDocsGraphicPlacement:
|
||||||
|
(placement: DocsGraphicPlacement) =>
|
||||||
|
({
|
||||||
|
chain,
|
||||||
|
editor,
|
||||||
|
}: {
|
||||||
|
chain: () => {
|
||||||
|
updateAttributes: (name: string, attrs: { placement: DocsGraphicPlacement }) => {
|
||||||
|
run: () => boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editor: { isActive: (name: string) => boolean }
|
||||||
|
}) => {
|
||||||
|
const name = editor.isActive("docsInlineGraphic") ? "docsInlineGraphic" : "docsGraphic"
|
||||||
|
return chain().updateAttributes(name, { placement }).run()
|
||||||
|
},
|
||||||
|
setDocsGraphicFloatSide:
|
||||||
|
(floatSide: DocsGraphicFloatSide) =>
|
||||||
|
({
|
||||||
|
chain,
|
||||||
|
editor,
|
||||||
|
}: {
|
||||||
|
chain: () => {
|
||||||
|
updateAttributes: (name: string, attrs: { floatSide: DocsGraphicFloatSide }) => {
|
||||||
|
run: () => boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editor: { isActive: (name: string) => boolean }
|
||||||
|
}) => {
|
||||||
|
const name = editor.isActive("docsInlineGraphic") ? "docsInlineGraphic" : "docsGraphic"
|
||||||
|
return chain().updateAttributes(name, { floatSide }).run()
|
||||||
|
},
|
||||||
|
bringDocsGraphicForward:
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
chain,
|
||||||
|
editor,
|
||||||
|
}: {
|
||||||
|
chain: () => {
|
||||||
|
updateAttributes: (name: string, attrs: { zIndex: number }) => { run: () => boolean }
|
||||||
|
}
|
||||||
|
editor: { isActive: (name: string) => boolean; getAttributes: (name: string) => Record<string, unknown> }
|
||||||
|
}) => {
|
||||||
|
const name = editor.isActive("docsInlineGraphic") ? "docsInlineGraphic" : "docsGraphic"
|
||||||
|
const z = Number(editor.getAttributes(name).zIndex ?? 0)
|
||||||
|
return chain().updateAttributes(name, { zIndex: z + 1 }).run()
|
||||||
|
},
|
||||||
|
sendDocsGraphicBackward:
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
chain,
|
||||||
|
editor,
|
||||||
|
}: {
|
||||||
|
chain: () => {
|
||||||
|
updateAttributes: (name: string, attrs: { zIndex: number }) => { run: () => boolean }
|
||||||
|
}
|
||||||
|
editor: { isActive: (name: string) => boolean; getAttributes: (name: string) => Record<string, unknown> }
|
||||||
|
}) => {
|
||||||
|
const name = editor.isActive("docsInlineGraphic") ? "docsInlineGraphic" : "docsGraphic"
|
||||||
|
const z = Number(editor.getAttributes(name).zIndex ?? 0)
|
||||||
|
return chain().updateAttributes(name, { zIndex: Math.max(0, z - 1) }).run()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DocsGraphic = Node.create({
|
||||||
|
name: "docsGraphic",
|
||||||
|
group: "block",
|
||||||
|
atom: true,
|
||||||
|
draggable: true,
|
||||||
|
selectable: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return graphicAttributes
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'div[data-type="docs-graphic"]' }]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ["div", mergeAttributes(HTMLAttributes, { "data-type": "docs-graphic" })]
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(DocsGraphicNodeView)
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return graphicCommands()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DocsInlineGraphic = Node.create({
|
||||||
|
name: "docsInlineGraphic",
|
||||||
|
group: "inline",
|
||||||
|
inline: true,
|
||||||
|
atom: true,
|
||||||
|
draggable: true,
|
||||||
|
selectable: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return graphicAttributes
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'span[data-type="docs-inline-graphic"]' }]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ["span", mergeAttributes(HTMLAttributes, { "data-type": "docs-inline-graphic" })]
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(DocsGraphicNodeView)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function buildInsertGraphicAttrs(
|
||||||
|
graphicType: DocsGraphicType,
|
||||||
|
partial: Partial<DocsGraphicAttrs> = {}
|
||||||
|
): DocsGraphicAttrs {
|
||||||
|
return mergeGraphicAttrs({ ...DOCS_GRAPHIC_DEFAULTS, graphicType, ...partial })
|
||||||
|
}
|
||||||
|
|
||||||
|
export { mergeGraphicAttrs }
|
||||||
209
lib/drive/extensions/docs-page-flow-decoration.ts
Normal file
209
lib/drive/extensions/docs-page-flow-decoration.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import { Extension } from "@tiptap/core"
|
||||||
|
import type { Editor } from "@tiptap/react"
|
||||||
|
import { Plugin, PluginKey } from "@tiptap/pm/state"
|
||||||
|
import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"
|
||||||
|
|
||||||
|
export const PAGE_FLOW_PLUGIN_KEY = new PluginKey<DecorationSet>("docsPageFlowDecoration")
|
||||||
|
|
||||||
|
function decorationSetsEqual(
|
||||||
|
current: DecorationSet | undefined,
|
||||||
|
next: DecorationSet
|
||||||
|
): boolean {
|
||||||
|
if (!current || current === DecorationSet.empty) {
|
||||||
|
return next === DecorationSet.empty || next.find(0, Number.MAX_SAFE_INTEGER).length === 0
|
||||||
|
}
|
||||||
|
const a = current.find(0, Number.MAX_SAFE_INTEGER)
|
||||||
|
const b = next.find(0, Number.MAX_SAFE_INTEGER)
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i].from !== b[i].from) return false
|
||||||
|
const aPush = (a[i].spec as { pushPx?: number }).pushPx
|
||||||
|
const bPush = (b[i].spec as { pushPx?: number }).pushPx
|
||||||
|
if (aPush !== bPush) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPageFlowMetrics(prose: HTMLElement): {
|
||||||
|
bodyAreaH: number
|
||||||
|
interPageSpacer: number
|
||||||
|
} | null {
|
||||||
|
const surface = prose.closest(".ultidrive-docs-editor-surface")
|
||||||
|
if (!surface) return null
|
||||||
|
const styles = getComputedStyle(surface)
|
||||||
|
const bodyAreaH = parseFloat(styles.getPropertyValue("--docs-body-area-h"))
|
||||||
|
const interPageSpacer = parseFloat(styles.getPropertyValue("--docs-inter-page-spacer"))
|
||||||
|
if (!Number.isFinite(bodyAreaH) || bodyAreaH <= 0) return null
|
||||||
|
if (!Number.isFinite(interPageSpacer) || interPageSpacer <= 0) return null
|
||||||
|
return { bodyAreaH, interPageSpacer }
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockDomAtOffset(view: EditorView, offset: number): HTMLElement | null {
|
||||||
|
const dom = view.nodeDOM(offset)
|
||||||
|
if (dom instanceof HTMLElement) {
|
||||||
|
if (dom.parentElement === view.dom) return dom
|
||||||
|
let el: HTMLElement | null = dom
|
||||||
|
while (el && el.parentElement !== view.dom) el = el.parentElement
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Block height in prose px (offsetHeight + bottom margin; immune to canvas scale). */
|
||||||
|
function measureBlockFlowHeight(dom: HTMLElement): number {
|
||||||
|
const style = getComputedStyle(dom)
|
||||||
|
const marginBottom = parseFloat(style.marginBottom) || 0
|
||||||
|
return dom.offsetHeight + marginBottom
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSpacerElement(height: number): HTMLElement {
|
||||||
|
const el = document.createElement("div")
|
||||||
|
el.className = "docs-page-flow-spacer"
|
||||||
|
el.style.cssText = `display:block;width:100%;height:${height}px;margin:0;padding:0;border:0;flex-shrink:0`
|
||||||
|
el.setAttribute("aria-hidden", "true")
|
||||||
|
el.contentEditable = "false"
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulate vertical flow without reading pushed DOM positions (avoids clearing decorations). */
|
||||||
|
export function computePageFlowPushes(
|
||||||
|
blocks: Array<{ height: number }>,
|
||||||
|
bodyAreaH: number,
|
||||||
|
interPageSpacer: number
|
||||||
|
): Array<{ blockIndex: number; pushPx: number; breakY: number }> {
|
||||||
|
const pageStep = bodyAreaH + interPageSpacer
|
||||||
|
const pushes: Array<{ blockIndex: number; pushPx: number; breakY: number }> = []
|
||||||
|
let simulatedY = 0
|
||||||
|
let nextBreakY = bodyAreaH
|
||||||
|
|
||||||
|
blocks.forEach(({ height: blockH }, blockIndex) => {
|
||||||
|
if (blockH <= 0) return
|
||||||
|
|
||||||
|
while (simulatedY + blockH > nextBreakY) {
|
||||||
|
if (simulatedY < nextBreakY) {
|
||||||
|
const pushPx = nextBreakY - simulatedY + interPageSpacer
|
||||||
|
pushes.push({ blockIndex, pushPx, breakY: nextBreakY })
|
||||||
|
simulatedY = nextBreakY + interPageSpacer
|
||||||
|
nextBreakY += pageStep
|
||||||
|
break
|
||||||
|
}
|
||||||
|
nextBreakY += pageStep
|
||||||
|
}
|
||||||
|
|
||||||
|
simulatedY += blockH
|
||||||
|
})
|
||||||
|
|
||||||
|
return pushes
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeSimulatedLayoutHeight(
|
||||||
|
blocks: Array<{ height: number }>,
|
||||||
|
bodyAreaH: number,
|
||||||
|
interPageSpacer: number
|
||||||
|
): number {
|
||||||
|
const pageStep = bodyAreaH + interPageSpacer
|
||||||
|
let simulatedY = 0
|
||||||
|
let nextBreakY = bodyAreaH
|
||||||
|
|
||||||
|
for (const { height: blockH } of blocks) {
|
||||||
|
if (blockH <= 0) continue
|
||||||
|
while (simulatedY + blockH > nextBreakY) {
|
||||||
|
if (simulatedY < nextBreakY) {
|
||||||
|
simulatedY = nextBreakY + interPageSpacer
|
||||||
|
nextBreakY += pageStep
|
||||||
|
break
|
||||||
|
}
|
||||||
|
nextBreakY += pageStep
|
||||||
|
}
|
||||||
|
simulatedY += blockH
|
||||||
|
}
|
||||||
|
|
||||||
|
return simulatedY
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFlowBlocks(view: EditorView): Array<{ height: number; offset: number }> {
|
||||||
|
const blocks: Array<{ height: number; offset: number }> = []
|
||||||
|
view.state.doc.forEach((node, offset) => {
|
||||||
|
if (!node.isBlock || node.type.name === "doc") return
|
||||||
|
const dom = blockDomAtOffset(view, offset)
|
||||||
|
if (!dom) return
|
||||||
|
const blockH = measureBlockFlowHeight(dom)
|
||||||
|
if (blockH <= 0) return
|
||||||
|
blocks.push({ height: blockH, offset })
|
||||||
|
})
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build page-flow spacer widgets for blocks that cross a page body boundary. */
|
||||||
|
export function buildPageFlowDecorations(view: EditorView): DecorationSet {
|
||||||
|
const metrics = readPageFlowMetrics(view.dom)
|
||||||
|
if (!metrics) return DecorationSet.empty
|
||||||
|
|
||||||
|
const { bodyAreaH, interPageSpacer } = metrics
|
||||||
|
const blocks = collectFlowBlocks(view)
|
||||||
|
|
||||||
|
const pushes = computePageFlowPushes(
|
||||||
|
blocks.map((b) => ({ height: b.height })),
|
||||||
|
bodyAreaH,
|
||||||
|
interPageSpacer
|
||||||
|
)
|
||||||
|
|
||||||
|
const decorations = pushes.map(({ blockIndex, pushPx, breakY }) => {
|
||||||
|
const block = blocks[blockIndex]
|
||||||
|
return Decoration.widget(
|
||||||
|
block.offset,
|
||||||
|
() => createSpacerElement(pushPx),
|
||||||
|
{ side: -1, key: `page-flow-spacer-${block.offset}-${breakY}`, pushPx }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return DecorationSet.create(view.state.doc, decorations)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply page-flow layout once. Returns true if decorations changed. */
|
||||||
|
export function applyPageFlowLayout(editor: Editor): boolean {
|
||||||
|
if (editor.isDestroyed || !editor.isInitialized) return false
|
||||||
|
const view = editor.view
|
||||||
|
const next = buildPageFlowDecorations(view)
|
||||||
|
const current = PAGE_FLOW_PLUGIN_KEY.getState(view.state)
|
||||||
|
if (decorationSetsEqual(current, next)) return false
|
||||||
|
const tr = view.state.tr.setMeta(PAGE_FLOW_PLUGIN_KEY, next)
|
||||||
|
tr.setMeta("addToHistory", false)
|
||||||
|
view.dispatch(tr)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure helper for tests: return count of inter-page pushes. */
|
||||||
|
export function countPageFlowSpacers(
|
||||||
|
blocks: Array<{ height: number }>,
|
||||||
|
bodyAreaH: number,
|
||||||
|
interPageSpacer: number
|
||||||
|
): number {
|
||||||
|
return computePageFlowPushes(blocks, bodyAreaH, interPageSpacer).length
|
||||||
|
}
|
||||||
|
|
||||||
|
/** State-only plugin: layout is driven from docs-page-view (no view plugin / observers). */
|
||||||
|
export const DocsPageFlowDecoration = Extension.create({
|
||||||
|
name: "docsPageFlowDecoration",
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: PAGE_FLOW_PLUGIN_KEY,
|
||||||
|
state: {
|
||||||
|
init: () => DecorationSet.empty,
|
||||||
|
apply(tr, set) {
|
||||||
|
const next = tr.getMeta(PAGE_FLOW_PLUGIN_KEY)
|
||||||
|
if (next instanceof DecorationSet) return next
|
||||||
|
return set.map(tr.mapping, tr.doc)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
decorations(state) {
|
||||||
|
return PAGE_FLOW_PLUGIN_KEY.getState(state) ?? DecorationSet.empty
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import type { Editor } from "@tiptap/react"
|
import type { Editor } from "@tiptap/react"
|
||||||
|
import { readPageFlowMetrics } from "@/lib/drive/extensions/docs-page-flow-decoration"
|
||||||
|
|
||||||
/** Focus editor at viewport coords; clamp to prose bounds when click is on padding. */
|
/** Focus editor at viewport coords; clamp to prose bounds when click is on padding. */
|
||||||
export function focusEditorAtPointer(
|
export function focusEditorAtPointer(
|
||||||
@ -14,7 +15,8 @@ export function focusEditorAtPointer(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = editor.view.dom.getBoundingClientRect()
|
const prose = editor.view.dom
|
||||||
|
const rect = prose.getBoundingClientRect()
|
||||||
if (rect.width <= 0 || rect.height <= 0) {
|
if (rect.width <= 0 || rect.height <= 0) {
|
||||||
editor.chain().focus("end").run()
|
editor.chain().focus("end").run()
|
||||||
return
|
return
|
||||||
@ -28,5 +30,14 @@ export function focusEditorAtPointer(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const metrics = readPageFlowMetrics(prose)
|
||||||
|
if (metrics) {
|
||||||
|
const localY = clientY - rect.top
|
||||||
|
if (localY >= metrics.bodyAreaH) {
|
||||||
|
editor.chain().focus("end").run()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
editor.chain().focus(clientY < rect.top + rect.height / 2 ? "start" : "end").run()
|
editor.chain().focus(clientY < rect.top + rect.height / 2 ? "start" : "end").run()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,9 @@ import CollaborationCaret from "@tiptap/extension-collaboration-caret"
|
|||||||
import type { HocuspocusProvider } from "@hocuspocus/provider"
|
import type { HocuspocusProvider } from "@hocuspocus/provider"
|
||||||
import type * as Y from "yjs"
|
import type * as Y from "yjs"
|
||||||
import { DocsEditorShortcuts } from "@/lib/drive/docs-editor-shortcuts"
|
import { DocsEditorShortcuts } from "@/lib/drive/docs-editor-shortcuts"
|
||||||
|
import { DocsGraphic, DocsInlineGraphic } from "@/lib/drive/extensions/docs-graphic"
|
||||||
|
import { DocsGraphicPasteDrop } from "@/lib/drive/extensions/docs-graphic-paste-drop"
|
||||||
|
import { DocsPageFlowDecoration } from "@/lib/drive/extensions/docs-page-flow-decoration"
|
||||||
|
|
||||||
export function buildRichTextExtensions(options?: {
|
export function buildRichTextExtensions(options?: {
|
||||||
collaboration?: { document: Y.Doc }
|
collaboration?: { document: Y.Doc }
|
||||||
@ -61,6 +64,10 @@ export function buildRichTextExtensions(options?: {
|
|||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
|
DocsGraphic,
|
||||||
|
DocsInlineGraphic,
|
||||||
|
DocsGraphicPasteDrop,
|
||||||
|
DocsPageFlowDecoration,
|
||||||
Image.configure({ inline: true, allowBase64: true }),
|
Image.configure({ inline: true, allowBase64: true }),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: options?.placeholder ?? "Commencez à écrire…",
|
placeholder: options?.placeholder ?? "Commencez à écrire…",
|
||||||
@ -73,3 +80,36 @@ export function buildRichTextExtensions(options?: {
|
|||||||
|
|
||||||
export const RICHTEXT_EDITOR_CLASS =
|
export const RICHTEXT_EDITOR_CLASS =
|
||||||
"ultidrive-richtext-editor max-w-none outline-none focus:outline-none prose prose-sm"
|
"ultidrive-richtext-editor max-w-none outline-none focus:outline-none prose prose-sm"
|
||||||
|
|
||||||
|
export const RICHTEXT_REGION_EDITOR_CLASS =
|
||||||
|
"ultidrive-richtext-region-editor max-w-none outline-none focus:outline-none prose prose-sm"
|
||||||
|
|
||||||
|
/** Full-feature extensions for inline header/footer editors (no collab, no page flow). */
|
||||||
|
export function buildRegionEditorExtensions(placeholder?: string): Extensions {
|
||||||
|
return [
|
||||||
|
StarterKit.configure({ heading: { levels: [1, 2, 3, 4] } }),
|
||||||
|
Underline,
|
||||||
|
Link.configure({ openOnClick: false }),
|
||||||
|
TextStyle,
|
||||||
|
FontFamily,
|
||||||
|
FontSize,
|
||||||
|
Color,
|
||||||
|
BackgroundColor,
|
||||||
|
Highlight.configure({ multicolor: true }),
|
||||||
|
TextAlign.configure({
|
||||||
|
types: ["heading", "paragraph"],
|
||||||
|
alignments: ["left", "center", "right", "justify"],
|
||||||
|
}),
|
||||||
|
Table.configure({ resizable: true }),
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableHeader,
|
||||||
|
DocsGraphic,
|
||||||
|
DocsInlineGraphic,
|
||||||
|
DocsGraphicPasteDrop,
|
||||||
|
Image.configure({ inline: true, allowBase64: true }),
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: placeholder ?? "Saisissez un en-tête ou un pied de page",
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
111
lib/drive/richtext-import.test.ts
Normal file
111
lib/drive/richtext-import.test.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
import {
|
||||||
|
normalizeImportedGraphics,
|
||||||
|
normalizeLegacyImageAttrs,
|
||||||
|
} from "./docs-graphic-import.ts"
|
||||||
|
import { extractGraphicPositionsFromDocx } from "./docx-position-import.ts"
|
||||||
|
import { computeCropImageStyle } from "./docs-graphic-types.ts"
|
||||||
|
|
||||||
|
function normalizeImportedTipTap(content: Record<string, unknown>) {
|
||||||
|
return normalizeImportedGraphics(normalizeLegacyImageAttrs(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("richtext-import chain", () => {
|
||||||
|
it("preserves placement and rotation through full chain", () => {
|
||||||
|
const result = normalizeImportedTipTap({
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
attrs: {
|
||||||
|
src: "data:image/png;base64,abc",
|
||||||
|
width: 200,
|
||||||
|
height: 120,
|
||||||
|
placement: "absolute",
|
||||||
|
wrap: "square",
|
||||||
|
x: 40,
|
||||||
|
y: 20,
|
||||||
|
rotationDeg: 15,
|
||||||
|
floatSide: "right",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const node = result.content?.[0] as { type?: string; attrs?: Record<string, unknown> }
|
||||||
|
assert.equal(node.type, "docsGraphic")
|
||||||
|
assert.equal(node.attrs?.placement, "absolute")
|
||||||
|
assert.equal(node.attrs?.x, 40)
|
||||||
|
assert.equal(node.attrs?.y, 20)
|
||||||
|
assert.equal(node.attrs?.rotationDeg, 15)
|
||||||
|
assert.equal(node.attrs?.floatSide, "right")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves crop attrs through full chain", () => {
|
||||||
|
const result = normalizeImportedTipTap({
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
attrs: {
|
||||||
|
src: "data:image/png;base64,abc",
|
||||||
|
cropX: 0.1,
|
||||||
|
cropY: 0.2,
|
||||||
|
cropWidth: 0.8,
|
||||||
|
cropHeight: 0.7,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const node = result.content?.[0] as { attrs?: Record<string, unknown> }
|
||||||
|
assert.equal(node.attrs?.cropX, 0.1)
|
||||||
|
assert.equal(node.attrs?.cropY, 0.2)
|
||||||
|
assert.equal(node.attrs?.cropWidth, 0.8)
|
||||||
|
assert.equal(node.attrs?.cropHeight, 0.7)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("docx-position-import", () => {
|
||||||
|
it("extractGraphicPositionsFromDocx parses inline extent", () => {
|
||||||
|
const archive = {
|
||||||
|
"word/document.xml": new TextEncoder().encode(`
|
||||||
|
<w:document>
|
||||||
|
<w:body>
|
||||||
|
<w:p>
|
||||||
|
<w:drawing>
|
||||||
|
<wp:inline>
|
||||||
|
<wp:extent cx="914400" cy="457200"/>
|
||||||
|
</wp:inline>
|
||||||
|
</w:drawing>
|
||||||
|
</w:p>
|
||||||
|
</w:body>
|
||||||
|
</w:document>
|
||||||
|
`),
|
||||||
|
}
|
||||||
|
const positions = extractGraphicPositionsFromDocx(archive)
|
||||||
|
assert.equal(positions.length, 1)
|
||||||
|
assert.equal(positions[0]?.width, 96)
|
||||||
|
assert.equal(positions[0]?.height, 48)
|
||||||
|
assert.equal(positions[0]?.placement, "inline")
|
||||||
|
assert.equal(positions[0]?.wrap, "inline")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("docs-graphic-types crop", () => {
|
||||||
|
it("computeCropImageStyle returns styles when crop active", () => {
|
||||||
|
const style = computeCropImageStyle({
|
||||||
|
cropX: 0.1,
|
||||||
|
cropY: 0,
|
||||||
|
cropWidth: 0.8,
|
||||||
|
cropHeight: 1,
|
||||||
|
cropShape: "rect",
|
||||||
|
})
|
||||||
|
assert.ok(style.img.width)
|
||||||
|
assert.equal(style.clipPath, undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,4 +1,11 @@
|
|||||||
import mammoth from "mammoth"
|
import mammoth from "mammoth"
|
||||||
|
import {
|
||||||
|
normalizeImportedGraphics,
|
||||||
|
normalizeLegacyImageAttrs,
|
||||||
|
} from "@/lib/drive/docs-graphic-import"
|
||||||
|
import { enrichContentFromDocxDrawings } from "@/lib/drive/docx-drawing-import"
|
||||||
|
import { enrichGraphicsFromDocxPositions } from "@/lib/drive/docx-position-import"
|
||||||
|
import { extractDocxHeaderFooter } from "@/lib/drive/docx-header-footer-import"
|
||||||
import {
|
import {
|
||||||
extractDocxPageSetup,
|
extractDocxPageSetup,
|
||||||
type DocPageSetup,
|
type DocPageSetup,
|
||||||
@ -11,8 +18,6 @@ export type RichTextImportResult = {
|
|||||||
pageSetup?: DocPageSetup | null
|
pageSetup?: DocPageSetup | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const IMAGE_ATTR_KEYS = ["src", "alt", "title", "width", "height"] as const
|
|
||||||
|
|
||||||
function imageNodeFromElement(el: HTMLElement): TipTapJSON | null {
|
function imageNodeFromElement(el: HTMLElement): TipTapJSON | null {
|
||||||
const src = el.getAttribute("src")
|
const src = el.getAttribute("src")
|
||||||
if (!src) return null
|
if (!src) return null
|
||||||
@ -50,35 +55,10 @@ function inlineContentFromElement(el: HTMLElement): TipTapJSON[] {
|
|||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep only attrs supported by @tiptap/extension-image. */
|
/** Preserve graphic positioning attrs, then upgrade to docsGraphic nodes. */
|
||||||
export function normalizeImportedTipTap(content: TipTapJSON): TipTapJSON {
|
export function normalizeImportedTipTap(content: TipTapJSON): TipTapJSON {
|
||||||
const walk = (node: unknown): unknown => {
|
const withLegacy = normalizeLegacyImageAttrs(content)
|
||||||
if (!node || typeof node !== "object") return node
|
return normalizeImportedGraphics(withLegacy)
|
||||||
if (Array.isArray(node)) return node.map(walk)
|
|
||||||
const record = node as TipTapJSON
|
|
||||||
if (record.type === "image" && record.attrs && typeof record.attrs === "object") {
|
|
||||||
const raw = record.attrs as Record<string, unknown>
|
|
||||||
const attrs: Record<string, unknown> = {}
|
|
||||||
for (const key of IMAGE_ATTR_KEYS) {
|
|
||||||
const value = raw[key]
|
|
||||||
if (value != null && value !== "") attrs[key] = value
|
|
||||||
}
|
|
||||||
if (typeof attrs.src !== "string" || !attrs.src) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return { ...record, attrs }
|
|
||||||
}
|
|
||||||
if (Array.isArray(record.content)) {
|
|
||||||
const nextContent = record.content.map(walk).filter(Boolean)
|
|
||||||
return { ...record, content: nextContent }
|
|
||||||
}
|
|
||||||
return record
|
|
||||||
}
|
|
||||||
const normalized = walk(content)
|
|
||||||
if (!normalized || typeof normalized !== "object") {
|
|
||||||
return { type: "doc", content: [{ type: "paragraph" }] }
|
|
||||||
}
|
|
||||||
return normalized as TipTapJSON
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function htmlToTipTapDoc(html: string): TipTapJSON {
|
function htmlToTipTapDoc(html: string): TipTapJSON {
|
||||||
@ -148,11 +128,40 @@ function htmlToTipTapDoc(html: string): TipTapJSON {
|
|||||||
|
|
||||||
export async function importDocxToTipTap(buffer: ArrayBuffer): Promise<RichTextImportResult> {
|
export async function importDocxToTipTap(buffer: ArrayBuffer): Promise<RichTextImportResult> {
|
||||||
const pageSetup = await extractDocxPageSetup(buffer)
|
const pageSetup = await extractDocxPageSetup(buffer)
|
||||||
|
const headerFooter = await extractDocxHeaderFooter(buffer)
|
||||||
|
const normalizeRegion = (region: typeof headerFooter.header) => {
|
||||||
|
if (!region?.content) return region
|
||||||
|
return { ...region, content: normalizeImportedTipTap(region.content as TipTapJSON) }
|
||||||
|
}
|
||||||
|
const mergedPageSetup: DocPageSetup | null = pageSetup
|
||||||
|
? {
|
||||||
|
...pageSetup,
|
||||||
|
header: normalizeRegion(headerFooter.header ?? pageSetup.header ?? null),
|
||||||
|
footer: normalizeRegion(headerFooter.footer ?? pageSetup.footer ?? null),
|
||||||
|
headerFooterDifferentFirstPage:
|
||||||
|
headerFooter.headerFooterDifferentFirstPage ??
|
||||||
|
pageSetup.headerFooterDifferentFirstPage ??
|
||||||
|
false,
|
||||||
|
}
|
||||||
|
: headerFooter.header || headerFooter.footer
|
||||||
|
? {
|
||||||
|
widthMm: 210,
|
||||||
|
heightMm: 297,
|
||||||
|
marginsMm: { top: 25.4, right: 25.4, bottom: 25.4, left: 25.4 },
|
||||||
|
header: normalizeRegion(headerFooter.header ?? null),
|
||||||
|
footer: normalizeRegion(headerFooter.footer ?? null),
|
||||||
|
headerFooterDifferentFirstPage: headerFooter.headerFooterDifferentFirstPage ?? false,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { parseDOCX } = await import("@docen/import-docx")
|
const { parseDOCX } = await import("@docen/import-docx")
|
||||||
const content = await parseDOCX(buffer, { image: { crop: false } })
|
const content = await parseDOCX(buffer, { image: { crop: true } })
|
||||||
if (content && typeof content === "object") {
|
if (content && typeof content === "object") {
|
||||||
return { content: normalizeImportedTipTap(content as TipTapJSON), pageSetup }
|
let normalized = normalizeImportedTipTap(content as TipTapJSON)
|
||||||
|
normalized = await enrichGraphicsFromDocxPositions(buffer, normalized)
|
||||||
|
normalized = await enrichContentFromDocxDrawings(buffer, normalized)
|
||||||
|
return { content: normalized, pageSetup: mergedPageSetup }
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (process.env.NODE_ENV !== "production") {
|
if (process.env.NODE_ENV !== "production") {
|
||||||
@ -169,7 +178,10 @@ export async function importDocxToTipTap(buffer: ArrayBuffer): Promise<RichTextI
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return { content: htmlToTipTapDoc(result.value), pageSetup }
|
let content = htmlToTipTapDoc(result.value)
|
||||||
|
content = await enrichGraphicsFromDocxPositions(buffer, content)
|
||||||
|
content = await enrichContentFromDocxDrawings(buffer, content)
|
||||||
|
return { content, pageSetup: mergedPageSetup }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportTipTapToDocx(content: TipTapJSON): Promise<Blob> {
|
export async function exportTipTapToDocx(content: TipTapJSON): Promise<Blob> {
|
||||||
|
|||||||
185
lib/drive/use-docs-ruler-sync.ts
Normal file
185
lib/drive/use-docs-ruler-sync.ts
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import type { Editor } from "@tiptap/react"
|
||||||
|
import type { DocPageLayout } from "@/lib/drive/doc-page-setup"
|
||||||
|
import { DOCS_PAGE_GAP_PX } from "@/lib/drive/docs-page-layout-constants"
|
||||||
|
import {
|
||||||
|
docsPageLengthToScreen,
|
||||||
|
docsScreenLengthToPage,
|
||||||
|
docsZoomToScale,
|
||||||
|
} from "@/lib/drive/docs-ruler-scale"
|
||||||
|
|
||||||
|
export type DocsParagraphIndents = {
|
||||||
|
leftPx: number
|
||||||
|
firstLinePx: number
|
||||||
|
rightPx: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocsRulerSyncState = {
|
||||||
|
currentPage: number
|
||||||
|
pageTopInViewport: number
|
||||||
|
pageHeightScaled: number
|
||||||
|
/** Canvas client width — ruler track content area matches this. */
|
||||||
|
canvasWidth: number
|
||||||
|
/** Scrollbar gutter so ruler track centers like the canvas viewport. */
|
||||||
|
canvasScrollbarWidth: number
|
||||||
|
canvasScrollLeft: number
|
||||||
|
indents: DocsParagraphIndents
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_INDENTS = (layout: DocPageLayout): DocsParagraphIndents => ({
|
||||||
|
leftPx: layout.marginsPx.left,
|
||||||
|
firstLinePx: layout.marginsPx.left,
|
||||||
|
rightPx: layout.widthPx - layout.marginsPx.right,
|
||||||
|
})
|
||||||
|
|
||||||
|
function readIndents(
|
||||||
|
editor: Editor | null,
|
||||||
|
scale: number,
|
||||||
|
layout: DocPageLayout
|
||||||
|
): DocsParagraphIndents {
|
||||||
|
const fallback = DEFAULT_INDENTS(layout)
|
||||||
|
if (!editor || editor.isDestroyed) return fallback
|
||||||
|
|
||||||
|
const prose = editor.view.dom as HTMLElement | null
|
||||||
|
if (!prose) return fallback
|
||||||
|
|
||||||
|
const { from } = editor.state.selection
|
||||||
|
const domPos = editor.view.domAtPos(from)
|
||||||
|
let el = domPos.node as HTMLElement
|
||||||
|
if (el.nodeType === Node.TEXT_NODE) el = el.parentElement ?? el
|
||||||
|
|
||||||
|
const block = el.closest?.(
|
||||||
|
"p, h1, h2, h3, h4, li, blockquote, pre, td, th"
|
||||||
|
) as HTMLElement | null
|
||||||
|
if (!block || !prose.contains(block)) return fallback
|
||||||
|
|
||||||
|
const pageStack = prose.closest(
|
||||||
|
"[data-docs-page-stack]"
|
||||||
|
) as HTMLElement | null
|
||||||
|
if (!pageStack) return fallback
|
||||||
|
|
||||||
|
const pageRect = pageStack.getBoundingClientRect()
|
||||||
|
const blockRect = block.getBoundingClientRect()
|
||||||
|
const leftPx = docsScreenLengthToPage(blockRect.left - pageRect.left, scale)
|
||||||
|
const style = getComputedStyle(block)
|
||||||
|
const textIndent = parseFloat(style.textIndent) || 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
leftPx: Math.max(layout.marginsPx.left, leftPx),
|
||||||
|
firstLinePx: Math.max(layout.marginsPx.left, leftPx + textIndent),
|
||||||
|
rightPx: layout.widthPx - layout.marginsPx.right,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
computePageTopInViewport,
|
||||||
|
resolveCurrentPageInViewport,
|
||||||
|
} from "./docs-ruler-sync-math"
|
||||||
|
|
||||||
|
export function useDocsRulerSync({
|
||||||
|
canvasRef,
|
||||||
|
rulerTrackRef,
|
||||||
|
editor,
|
||||||
|
pageLayout,
|
||||||
|
zoom,
|
||||||
|
pageCount,
|
||||||
|
}: {
|
||||||
|
canvasRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
rulerTrackRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
editor: Editor | null
|
||||||
|
pageLayout: DocPageLayout
|
||||||
|
zoom: number
|
||||||
|
pageCount: number
|
||||||
|
narrowViewport: boolean
|
||||||
|
}) {
|
||||||
|
const scale = docsZoomToScale(zoom)
|
||||||
|
const pageHeight = pageLayout.heightPx
|
||||||
|
const pageHeightScaled = docsPageLengthToScreen(pageHeight, scale)
|
||||||
|
const gapScaled = docsPageLengthToScreen(DOCS_PAGE_GAP_PX, scale)
|
||||||
|
const pageStride = pageHeightScaled + gapScaled
|
||||||
|
|
||||||
|
const [state, setState] = useState<DocsRulerSyncState>(() => ({
|
||||||
|
currentPage: 0,
|
||||||
|
pageTopInViewport: 0,
|
||||||
|
pageHeightScaled,
|
||||||
|
canvasWidth: 0,
|
||||||
|
canvasScrollbarWidth: 0,
|
||||||
|
canvasScrollLeft: 0,
|
||||||
|
indents: DEFAULT_INDENTS(pageLayout),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const sync = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
const canvasRect = canvas.getBoundingClientRect()
|
||||||
|
const stack = canvas.querySelector("[data-docs-page-stack]") as HTMLElement | null
|
||||||
|
|
||||||
|
let pageTopInViewport = 0
|
||||||
|
let currentPage = 0
|
||||||
|
|
||||||
|
if (stack) {
|
||||||
|
const stackRect = stack.getBoundingClientRect()
|
||||||
|
const stackTopInViewport = stackRect.top - canvasRect.top
|
||||||
|
|
||||||
|
currentPage = resolveCurrentPageInViewport(
|
||||||
|
stackTopInViewport,
|
||||||
|
canvas.clientHeight,
|
||||||
|
pageStride,
|
||||||
|
pageCount
|
||||||
|
)
|
||||||
|
|
||||||
|
pageTopInViewport = computePageTopInViewport(
|
||||||
|
stackTopInViewport,
|
||||||
|
currentPage,
|
||||||
|
pageStride
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({
|
||||||
|
currentPage,
|
||||||
|
pageTopInViewport,
|
||||||
|
pageHeightScaled,
|
||||||
|
canvasWidth: canvas.clientWidth,
|
||||||
|
canvasScrollbarWidth: Math.max(0, canvas.offsetWidth - canvas.clientWidth),
|
||||||
|
canvasScrollLeft: canvas.scrollLeft,
|
||||||
|
indents: readIndents(editor, scale, pageLayout),
|
||||||
|
})
|
||||||
|
}, [canvasRef, editor, pageCount, pageHeightScaled, pageLayout, pageStride, scale])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sync()
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
canvas.addEventListener("scroll", sync, { passive: true })
|
||||||
|
const ro = new ResizeObserver(sync)
|
||||||
|
ro.observe(canvas)
|
||||||
|
const rulerTrack = rulerTrackRef.current
|
||||||
|
if (rulerTrack) ro.observe(rulerTrack)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
canvas.removeEventListener("scroll", sync)
|
||||||
|
ro.disconnect()
|
||||||
|
}
|
||||||
|
}, [canvasRef, rulerTrackRef, sync])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || editor.isDestroyed) return
|
||||||
|
const onSelection = () => sync()
|
||||||
|
editor.on("selectionUpdate", onSelection)
|
||||||
|
editor.on("transaction", onSelection)
|
||||||
|
return () => {
|
||||||
|
editor.off("selectionUpdate", onSelection)
|
||||||
|
editor.off("transaction", onSelection)
|
||||||
|
}
|
||||||
|
}, [editor, sync])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sync()
|
||||||
|
}, [pageLayout, zoom, pageCount, sync])
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
@ -36,6 +36,290 @@
|
|||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ultidrive-richtext-editor .ProseMirror {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-richtext-editor .docs-graphic-host {
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-richtext-editor .docs-graphic-host--inline {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-richtext-editor .docs-graphic {
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-richtext-editor .docs-graphic--behind {
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-richtext-editor .docs-graphic--front {
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-richtext-editor .docs-graphic__content {
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-richtext-editor .docs-graphic-outline {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 2px solid #1a73e8;
|
||||||
|
border-radius: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-richtext-editor .docs-graphic-handle {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-richtext-editor .docs-graphic--selected .docs-graphic__content {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-richtext-editor .ProseMirror-selectednode .docs-graphic-outline {
|
||||||
|
border-color: #1a73e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-richtext-editor .docs-graphic--interacting {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-richtext-editor .docs-graphic--cropping {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-richtext-editor .docs-graphic-rotate-handle {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-richtext-editor .docs-page-region {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-band {
|
||||||
|
color: #3c4043;
|
||||||
|
z-index: 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-band--editing {
|
||||||
|
z-index: 26 !important;
|
||||||
|
background-color: var(--docs-hf-page-color, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-hit-area {
|
||||||
|
z-index: 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-editing-backdrop {
|
||||||
|
z-index: 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-separator {
|
||||||
|
z-index: 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-chrome {
|
||||||
|
z-index: 27;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-band--editing.docs-hf-band--header .docs-region-editor-root {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-band--editing.docs-hf-band--footer .docs-region-editor-root {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-band--header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-band--footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-band--editing .docs-region-editor-root,
|
||||||
|
.docs-hf-band--editing .docs-region-editor,
|
||||||
|
.docs-hf-band--editing .ultidrive-richtext-region-editor {
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
max-height: none !important;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-band--editing .ultidrive-richtext-region-editor .ProseMirror {
|
||||||
|
height: auto !important;
|
||||||
|
max-height: none !important;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-band .docs-region-editor-root,
|
||||||
|
.docs-hf-band .docs-region-editor-root--grow,
|
||||||
|
.docs-hf-band .docs-region-editor--grow {
|
||||||
|
height: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-band .docs-region-editor-root .ultidrive-richtext-region-editor,
|
||||||
|
.docs-hf-band .docs-region-editor-root .ultidrive-richtext-region-editor .ProseMirror {
|
||||||
|
height: auto;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-band:not(.docs-hf-band--editing) .docs-region-editor-root .ultidrive-richtext-region-editor .ProseMirror {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-band .ultidrive-richtext-region-editor {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-band .ultidrive-richtext-region-editor .ProseMirror {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-band .ultidrive-richtext-region-editor.prose :where(p, h1, h2, h3, h4):first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-band .ultidrive-richtext-region-editor.prose :where(p, h1, h2, h3, h4):last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-band--footer .docs-region-editor-root {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-chrome__bar {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-chrome__label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #3c4043;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-chrome__checkbox-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #3c4043;
|
||||||
|
line-height: 1.2;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-docs-editor-surface {
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
z-index: 16;
|
||||||
|
transition: padding 60ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-docs-editor-surface--paginated {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-docs-editor-surface--paginated .ProseMirror {
|
||||||
|
min-height: var(--docs-prose-min-height);
|
||||||
|
max-height: var(--docs-prose-min-height);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-chrome__checkbox {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
min-width: 14px;
|
||||||
|
min-height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 2px solid #5f6368;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-chrome__checkbox[data-state="checked"] {
|
||||||
|
background: #1a73e8;
|
||||||
|
border-color: #1a73e8;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-chrome__checkbox[data-state="checked"] svg {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-chrome__options {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a73e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-chrome__options:hover {
|
||||||
|
color: #174ea6;
|
||||||
|
background-color: rgba(26, 115, 232, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .docs-hf-chrome__options {
|
||||||
|
color: #8ab4f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .docs-hf-chrome__options:hover {
|
||||||
|
color: #aecbfa;
|
||||||
|
background-color: rgba(138, 180, 248, 0.16) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-hf-band .ultidrive-richtext-region-editor .ProseMirror p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-docs-editor-surface--dimmed {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-docs-editor-surface--dimmed .ProseMirror {
|
||||||
|
opacity: 0.45;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-page-flow-spacer {
|
||||||
|
width: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-docs-editor-surface .ProseMirror .docs-page-flow-spacer {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.ultidrive-richtext-editor td,
|
.ultidrive-richtext-editor td,
|
||||||
.ultidrive-richtext-editor th {
|
.ultidrive-richtext-editor th {
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
@ -161,10 +445,59 @@ html.dark [data-docs-menu-surface] [data-slot="menubar-separator"] {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.docs-toolbar-ruler-row {
|
||||||
|
z-index: 15;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-rulers-left-rail {
|
||||||
|
z-index: 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-ruler-drag-handle:hover svg path,
|
||||||
|
.docs-ruler-drag-handle:active svg path {
|
||||||
|
fill: #174ea6;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .docs-ruler-drag-handle:hover svg path,
|
||||||
|
html.dark .docs-ruler-drag-handle:active svg path {
|
||||||
|
fill: #8ab4f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-ruler-margin-tooltip {
|
||||||
|
background-color: #3c4043;
|
||||||
|
border: 1px solid #5f6368;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .docs-ruler-margin-tooltip {
|
||||||
|
background-color: #e8eaed;
|
||||||
|
color: #202124;
|
||||||
|
border-color: #9aa0a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-toolbar-shell .docs-toolbar + .docs-toolbar-ruler-row {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-editor-workspace .ultidrive-docs-canvas {
|
||||||
|
background-color: #f9fbfd;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .docs-editor-workspace .ultidrive-docs-canvas {
|
||||||
|
background-color: #202124;
|
||||||
|
}
|
||||||
|
|
||||||
.docs-menu-sub-content {
|
.docs-menu-sub-content {
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-slot="menubar-sub-content"][data-state="closed"] {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.docs-menu-item-icon {
|
.docs-menu-item-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
@ -178,14 +511,67 @@ html.dark .docs-menu-item-icon {
|
|||||||
color: #e8eaed;
|
color: #e8eaed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.docs-menu-mode-item {
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-menu-checkbox-item {
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-menu-shortcut-sequence {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultidrive-docs-editor-surface--compact {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-show-non-printable .ultidrive-richtext-editor p::after,
|
||||||
|
.docs-show-non-printable .ProseMirror p::after {
|
||||||
|
content: "¶";
|
||||||
|
color: #bdc1c6;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-left: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-show-non-printable .ultidrive-richtext-editor p:empty::before,
|
||||||
|
.docs-show-non-printable .ProseMirror p:empty::before {
|
||||||
|
content: "¶";
|
||||||
|
color: #bdc1c6;
|
||||||
|
font-size: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-editor-mode-suggest .ultidrive-richtext-editor,
|
||||||
|
.docs-editor-mode-suggest .ProseMirror {
|
||||||
|
caret-color: #1967d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-editor-mode-view .ultidrive-richtext-editor,
|
||||||
|
.docs-editor-mode-view .ProseMirror {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.docs-toolbar-shell {
|
.docs-toolbar-shell {
|
||||||
padding: 0 12px 8px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-toolbar-shell--collapsed {
|
.docs-toolbar-shell--collapsed {
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.docs-toolbar-shell > .docs-toolbar {
|
||||||
|
padding-inline: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.docs-toolbar {
|
.docs-toolbar {
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
color: #202124;
|
color: #202124;
|
||||||
@ -414,6 +800,7 @@ html.dark .docs-menu-item-icon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ultidrive-docs-editor-surface .ProseMirror {
|
.ultidrive-docs-editor-surface .ProseMirror {
|
||||||
|
display: flow-root;
|
||||||
min-height: var(--docs-prose-min-height, 600px);
|
min-height: var(--docs-prose-min-height, 600px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -471,6 +858,7 @@ html.dark .docs-menu-item-icon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ultidrive-richtext-editor .ProseMirror {
|
.ultidrive-richtext-editor .ProseMirror {
|
||||||
|
position: relative;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
caret-color: #000000;
|
caret-color: #000000;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user