feat(drive): refactor document and drawing editors with new metadata handling
Some checks are pending
E2E / Playwright e2e (push) Waiting to run

- Replaced suite page metadata with drive-specific metadata for document and drawing editors.
- Introduced new `driveEditorPageMetadata` function to manage titles and favicons based on editor type.
- Updated layout components for document and drawing editors to utilize the new metadata structure.
- Enhanced document title handling in various editor components to reflect the current editing context.
- Added new SVG icons for UltiDocs, UltiSheets, UltiSlides, and UltiDraw to improve visual consistency across editors.
- Improved print styles and layout handling for better document rendering in print and PDF formats.
This commit is contained in:
R3D347HR4Y 2026-06-15 15:51:09 +02:00
parent 8f81d7aba1
commit 82ca9a27db
30 changed files with 616 additions and 139 deletions

View File

@ -1,11 +1,8 @@
import type { Metadata } from "next"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
import { driveEditorPageMetadata } from "@/lib/drive/drive-editor-metadata"
export async function generateMetadata(): Promise<Metadata> {
return suitePageMetadata({
app: "drive",
titleSegment: "Document",
})
return driveEditorPageMetadata("docs", "Document")
}
export default function DriveDocsEditLayout({

View File

@ -0,0 +1,14 @@
import type { Metadata } from "next"
import { driveEditorPageMetadata } from "@/lib/drive/drive-editor-metadata"
export async function generateMetadata(): Promise<Metadata> {
return driveEditorPageMetadata("draw", "Dessin")
}
export default function DriveDrawEditLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

View File

@ -1,6 +1,7 @@
import type { Metadata } from "next"
import { displayFileBaseName } from "@/lib/drive/display-file-name"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
import { driveEditorPageMetadata } from "@/lib/drive/drive-editor-metadata"
import { resolveOnlyOfficeEditorKind } from "@/lib/drive/onlyoffice-formats"
type LayoutProps = {
children: React.ReactNode
@ -12,10 +13,8 @@ export async function generateMetadata({ params }: LayoutProps): Promise<Metadat
const raw = decodeURIComponent(fileId)
const baseName = raw.split("/").filter(Boolean).pop() ?? raw
const name = displayFileBaseName(baseName)
return suitePageMetadata({
app: "drive",
titleSegment: name,
})
const kind = resolveOnlyOfficeEditorKind({ name: baseName })
return driveEditorPageMetadata(kind, name)
}
export default function EditLayout({ children }: LayoutProps) {

View File

@ -13,6 +13,7 @@ import { displayFileBaseName } from "@/lib/drive/display-file-name"
import { resolveRenameName } from "@/lib/drive/drive-default-name"
import { driveFolderHref } from "@/lib/drive/drive-sidebar-tree"
import { buildDriveEditHref, resolveDriveEditReturnTo } from "@/lib/drive/drive-url"
import { resolveOnlyOfficeEditorKind } from "@/lib/drive/onlyoffice-formats"
import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
@ -48,7 +49,8 @@ export function OfficeEditor({
const fileName = fileNameFromPath(displayPath)
const title = displayFileBaseName(fileName)
useDriveDocumentTitle(title)
const editorKind = resolveOnlyOfficeEditorKind({ name: fileName })
useDriveDocumentTitle(title, editorKind)
const backHref = useMemo(
() =>

View File

@ -10,6 +10,7 @@ import { displayFileBaseName } from "@/lib/drive/display-file-name"
import { getGuestEditorIdentity } from "@/lib/drive/guest-editor-identity"
import { resolvePublicShareEditReturnTo, shouldShowPublicShareEditorBack } from "@/lib/drive/public-share-url"
import type { PublicShareRootType } from "@/lib/drive/public-share-url"
import { resolveOnlyOfficeEditorKind } from "@/lib/drive/onlyoffice-formats"
import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title"
function fileNameFromPath(filePath: string, fallback?: string): string {
@ -44,7 +45,8 @@ export function PublicOfficeEditor({
const fileName = fileDisplayName || fileNameFromPath(filePath)
const title = displayFileBaseName(fileName)
useDriveDocumentTitle(title)
const editorKind = resolveOnlyOfficeEditorKind({ name: fileName })
useDriveDocumentTitle(title, editorKind)
const backHref = useMemo(
() => resolvePublicShareEditReturnTo(token, returnTo, filePath),

View File

@ -47,7 +47,7 @@ export function PublicRichTextEditor({
const fileName = fileDisplayName || fileNameFromPath(filePath)
const title = displayFileBaseName(fileName)
useDriveDocumentTitle(title)
useDriveDocumentTitle(title, "docs")
const backHref = useMemo(
() => resolvePublicShareEditReturnTo(token, returnTo, filePath),

View File

@ -49,7 +49,7 @@ export function PublicUltidrawEditor({
const fileName = fileDisplayName || fileNameFromPath(filePath)
const title = displayFileBaseName(fileName)
useDriveDocumentTitle(title)
useDriveDocumentTitle(title, "draw")
const backHref = useMemo(
() => resolvePublicShareEditReturnTo(token, returnTo, filePath),

View File

@ -918,7 +918,7 @@ export function RichTextDocumentEditor({
/>
) : null}
{chrome ? (
<div className="flex min-h-0 flex-1 flex-row">
<div className="flex min-h-0 min-w-0 flex-1 flex-row">
<DocsEditorWorkspace
editor={editor}
pageLayout={pageLayout}

View File

@ -53,7 +53,7 @@ export function RichTextEditor({ fileId }: { fileId: string }) {
const fileName = file?.name ?? fileNameFromPath(displayPath)
const title = displayFileBaseName(fileName)
useDriveDocumentTitle(title)
useDriveDocumentTitle(title, "docs")
const [backHref, setBackHref] = useState("/drive")

View File

@ -38,7 +38,7 @@ export function DocsBodyMarginMasks({
return (
<div key={`body-mask-${index}`} aria-hidden>
<div
className="pointer-events-none absolute z-[15]"
className="docs-body-margin-mask pointer-events-none absolute z-[15] box-border border-l border-r border-[#dadce0]"
style={{
top: pageTop,
left: 0,
@ -48,7 +48,7 @@ export function DocsBodyMarginMasks({
}}
/>
<div
className="pointer-events-none absolute z-[15]"
className="docs-body-margin-mask pointer-events-none absolute z-[15] box-border border-l border-r border-[#dadce0]"
style={{
top: footerTop,
left: 0,

View File

@ -178,7 +178,12 @@ export function DocsChrome({
) : null}
</div>
<div className="-mt-1 flex min-w-0 items-center overflow-x-auto overflow-y-visible">
<div
className={cn(
"-mt-1 flex min-w-0 items-center overflow-x-auto overflow-y-visible",
"[scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
)}
>
<DocsMenubar
className="docs-menubar shrink-0"
viewMenuActions={viewMenuActions}

View File

@ -139,7 +139,7 @@ export function DocsEditorWorkspace({
}, [onPageStackReady, pageCount, showLayout, zoom])
return (
<div className="docs-editor-workspace flex min-h-0 flex-1 flex-col">
<div className="docs-editor-workspace flex min-h-0 min-w-0 flex-1 flex-col">
<DocsRulerMarginDragTooltip tooltip={dragTooltip} />
<DocsGraphicFloatingToolbar
editor={editor}
@ -159,7 +159,7 @@ export function DocsEditorWorkspace({
{showToolbarShell ? (
<div
className={cn(
"docs-toolbar-shell shrink-0",
"docs-toolbar-shell min-w-0 shrink-0",
toolbarShellClassName
)}
>

View File

@ -218,6 +218,9 @@ export function DocsHeaderFooterBand({
footerGeom.zoneBottom - contentHeight - DOCS_HF_CHROME_BAR_PX
const chromeBarTop = isHeader ? headerChromeTop : footerChromeTop
const editLateralTop = isHeader ? zoneTop : footerChromeTop
const editLateralHeight = contentHeight + DOCS_HF_CHROME_BAR_PX
const [formatOpen, setFormatOpen] = useState(false)
const [pageNumOpen, setPageNumOpen] = useState(false)
const contentPersistTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
@ -348,6 +351,26 @@ export function DocsHeaderFooterBand({
aria-hidden
/>
<div
className="docs-hf-lateral-border pointer-events-none absolute border-l border-[#dadce0]"
style={{
top: editLateralTop,
left: 0,
height: editLateralHeight,
}}
aria-hidden
/>
<div
className="docs-hf-lateral-border pointer-events-none absolute border-r border-[#dadce0]"
style={{
top: editLateralTop,
right: 0,
width: 0,
height: editLateralHeight,
}}
aria-hidden
/>
{isHeader ? (
<div
className="docs-hf-separator pointer-events-none absolute border-t border-[#dadce0]"

View File

@ -1,15 +1,13 @@
import {
DOCS_LOGO_BODY,
DOCS_LOGO_COLOR,
DOCS_LOGO_FOLD,
DOCS_LOGO_FOLD_Y_OFFSET,
DOCS_LOGO_LINE_1,
DOCS_LOGO_LINE_2,
} from "@/lib/drive/docs-logo-paths"
import { cn } from "@/lib/utils"
/** material-symbols:description (Iconify MCP) — corps sans le pli. */
const BODY =
"M8 18h8v-2H8zm0-4h8v-2H8zm-2 8q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h8l6 6v12q0 .825-.587 1.413T18 22z"
const FOLD = "M13 7h5l-5-5z"
const LINE_1 = "M8 18h8v-2H8z"
const LINE_2 = "M8 14h8v-2H8z"
/** Décalage vertical du pli blanc (viewBox 24). */
const FOLD_Y_OFFSET = 1
export function DocsLogoIcon({ className }: { className?: string }) {
return (
<svg
@ -18,13 +16,13 @@ export function DocsLogoIcon({ className }: { className?: string }) {
className={cn("shrink-0", className)}
aria-hidden
>
<path fill="#4285F4" d={BODY} />
<path fill="#ffffff" d={LINE_1} />
<path fill="#ffffff" d={LINE_2} />
<path fill={DOCS_LOGO_COLOR} d={DOCS_LOGO_BODY} />
<path fill="#ffffff" d={DOCS_LOGO_LINE_1} />
<path fill="#ffffff" d={DOCS_LOGO_LINE_2} />
<path
fill="#ffffff"
d={FOLD}
transform={`translate(0 ${FOLD_Y_OFFSET})`}
d={DOCS_LOGO_FOLD}
transform={`translate(0 ${DOCS_LOGO_FOLD_Y_OFFSET})`}
/>
</svg>
)

View File

@ -557,8 +557,10 @@ function DocsToolbarInner({
fonts,
])
const reservedTrailingPx = showChromeToggle ? 44 : 0
const { containerRef, measureRef, visibleCount, hasOverflow } = useToolbarOverflow(
segments.length
segments.length,
reservedTrailingPx
)
if (!editor) return null
@ -569,7 +571,7 @@ function DocsToolbarInner({
const toolbarRow = (
<div
ref={containerRef}
className="docs-toolbar relative flex items-center gap-0 overflow-hidden px-1.5 py-0.5"
className="docs-toolbar relative flex min-w-0 w-full max-w-full items-center gap-0 overflow-hidden px-1.5 py-0.5"
>
<div
ref={measureRef}

View File

@ -46,7 +46,7 @@ export function UltidrawEditor({ fileId }: { fileId: string }) {
const fileName = file?.name ?? fileNameFromPath(displayPath)
const title = displayFileBaseName(fileName)
useDriveDocumentTitle(title)
useDriveDocumentTitle(title, "draw")
const [backHref, setBackHref] = useState("/drive")

View File

@ -5,6 +5,9 @@
},
{
"path": "../ulti-backend"
},
{
"path": "../ultisuite-deploy"
}
],
"settings": {}

View File

@ -0,0 +1,8 @@
/** Shared UltiDocs logo paths (material-symbols:description). Used by chrome + favicon. */
export const DOCS_LOGO_BODY =
"M8 18h8v-2H8zm0-4h8v-2H8zm-2 8q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h8l6 6v12q0 .825-.587 1.413T18 22z"
export const DOCS_LOGO_FOLD = "M13 7h5l-5-5z"
export const DOCS_LOGO_LINE_1 = "M8 18h8v-2H8z"
export const DOCS_LOGO_LINE_2 = "M8 14h8v-2H8z"
export const DOCS_LOGO_FOLD_Y_OFFSET = 1
export const DOCS_LOGO_COLOR = "#4285F4"

View File

@ -2,19 +2,179 @@ import type { DocPageLayout } from "@/lib/drive/doc-page-setup"
import { buildParagraphStylesCss } from "@/lib/drive/docs-paragraph-styles-css"
import type { DocParagraphStylesCatalog } from "@/lib/drive/docs-paragraph-styles"
import type { DocsExportSnapshot } from "@/lib/drive/docs-export-snapshot"
import { DOCS_PAGE_GAP_PX } from "@/lib/drive/docs-page-layout-constants"
const PRINT_STYLE_ID = "docs-print-dynamic-styles"
const PRINT_IFRAME_ID = "docs-print-iframe"
function buildPageRule(pageLayout: DocPageLayout): string {
const wMm = pageLayout.format.widthMm
const hMm = pageLayout.format.heightMm
const landscape =
pageLayout.format.widthMm > pageLayout.format.heightMm ||
(pageLayout.format.widthMm === pageLayout.format.heightMm && false)
const landscape = pageLayout.format.widthMm > pageLayout.format.heightMm
const size = landscape ? `${hMm}mm ${wMm}mm` : `${wMm}mm ${hMm}mm`
return `@page { size: ${size}; margin: 0; }`
}
function buildPrintLayoutRules(pageLayout: DocPageLayout): string {
const pageWidth = pageLayout.widthPx
const pageHeight = pageLayout.heightPx
return `
html, body {
margin: 0 !important;
padding: 0 !important;
background: white !important;
width: ${pageWidth}px !important;
min-height: 0 !important;
overflow: visible !important;
}
.docs-print-root {
margin: 0 !important;
padding: 0 !important;
width: ${pageWidth}px !important;
background: white !important;
}
[data-docs-page-stack] {
position: relative !important;
left: 0 !important;
top: 0 !important;
transform: none !important;
transform-origin: top left !important;
width: ${pageWidth}px !important;
margin: 0 !important;
}
.ultidrive-docs-page {
width: ${pageWidth}px !important;
height: ${pageHeight}px !important;
box-shadow: none !important;
border: none !important;
break-after: page;
page-break-after: always;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
.ultidrive-docs-page:last-child {
break-after: auto;
page-break-after: auto;
}
.docs-body-margin-mask {
border-color: transparent !important;
}
.docs-hf-chrome,
.docs-graphic-handle,
.docs-graphic-rotate-handle,
.docs-graphic-outline,
.docs-graphic-snap-guides {
display: none !important;
}
.docs-hf-band .docs-region-editor-root {
border: none !important;
background: transparent !important;
}
`
}
type StyleRestore = () => void
function patchInlineStyle(el: HTMLElement, property: string, value: string): StyleRestore {
const previous = el.style.getPropertyValue(property)
const priority = el.style.getPropertyPriority(property)
el.style.setProperty(property, value)
return () => {
if (previous) {
el.style.setProperty(property, previous, priority)
} else {
el.style.removeProperty(property)
}
}
}
/** Collapse on-screen page gaps and zoom offsets so print matches page size. */
function adjustPageStackForPrint(
stack: HTMLElement,
pageLayout: DocPageLayout,
pageCount: number
): StyleRestore {
const restores: StyleRestore[] = []
const pageWidth = pageLayout.widthPx
const pageHeight = pageLayout.heightPx
const pageStep = pageHeight + DOCS_PAGE_GAP_PX
const gapTotal = Math.max(0, pageCount - 1) * DOCS_PAGE_GAP_PX
restores.push(patchInlineStyle(stack, "transform", "none"))
restores.push(patchInlineStyle(stack, "left", "0"))
restores.push(patchInlineStyle(stack, "width", `${pageWidth}px`))
let wrapper = stack.parentElement
while (wrapper && !wrapper.classList.contains("ultidrive-docs-canvas")) {
restores.push(patchInlineStyle(wrapper, "width", `${pageWidth}px`))
restores.push(patchInlineStyle(wrapper, "height", "auto"))
restores.push(patchInlineStyle(wrapper, "min-height", "0"))
restores.push(patchInlineStyle(wrapper, "padding-top", "0"))
restores.push(patchInlineStyle(wrapper, "padding-bottom", "0"))
restores.push(patchInlineStyle(wrapper, "margin-left", "0"))
restores.push(patchInlineStyle(wrapper, "margin-right", "0"))
wrapper = wrapper.parentElement
}
const stackHeight = parseFloat(stack.style.height)
if (Number.isFinite(stackHeight) && gapTotal > 0) {
restores.push(patchInlineStyle(stack, "height", `${stackHeight - gapTotal}px`))
}
stack.querySelectorAll<HTMLElement>("[style]").forEach((el) => {
const top = parseFloat(el.style.top)
if (!Number.isFinite(top) || top <= 0) return
const pageIndex = Math.floor(top / pageStep)
const nextTop = top - pageIndex * DOCS_PAGE_GAP_PX
if (nextTop !== top) {
restores.push(patchInlineStyle(el, "top", `${nextTop}px`))
}
})
stack.querySelectorAll<HTMLElement>(".docs-page-flow-spacer").forEach((spacer) => {
const height = parseFloat(spacer.style.height)
if (Number.isFinite(height) && height >= DOCS_PAGE_GAP_PX) {
restores.push(
patchInlineStyle(spacer, "height", `${height - DOCS_PAGE_GAP_PX}px`)
)
}
})
const surface = stack.querySelector<HTMLElement>(".ultidrive-docs-editor-surface")
if (surface) {
const surfaceHeight = parseFloat(surface.style.height)
if (Number.isFinite(surfaceHeight) && gapTotal > 0) {
restores.push(
patchInlineStyle(surface, "height", `${surfaceHeight - gapTotal}px`)
)
}
}
return () => {
for (let i = restores.length - 1; i >= 0; i -= 1) {
restores[i]()
}
}
}
function buildDynamicPrintStyles(
pageLayout: DocPageLayout,
paragraphStyles: DocParagraphStylesCatalog
): string {
return [
buildPageRule(pageLayout),
buildPrintLayoutRules(pageLayout),
buildParagraphStylesCss(paragraphStyles, ".ultidrive-richtext-editor, .ultidrive-richtext-region-editor"),
].join("\n")
}
function injectPrintStyles(
pageLayout: DocPageLayout,
paragraphStyles: DocParagraphStylesCatalog
@ -24,10 +184,7 @@ function injectPrintStyles(
const style = document.createElement("style")
style.id = PRINT_STYLE_ID
style.textContent = [
buildPageRule(pageLayout),
buildParagraphStylesCss(paragraphStyles, ".ultidrive-richtext-editor, .ultidrive-richtext-region-editor"),
].join("\n")
style.textContent = buildDynamicPrintStyles(pageLayout, paragraphStyles)
document.head.appendChild(style)
return () => {
@ -35,6 +192,108 @@ function injectPrintStyles(
}
}
function copyDocumentStyles(targetDoc: Document): void {
document.querySelectorAll('style, link[rel="stylesheet"]').forEach((node) => {
targetDoc.head.appendChild(node.cloneNode(true))
})
}
async function waitForDocumentReady(doc: Document): Promise<void> {
const links = Array.from(doc.querySelectorAll('link[rel="stylesheet"]'))
await Promise.all(
links.map(
(link) =>
new Promise<void>((resolve) => {
const sheet = link as HTMLLinkElement
if (sheet.sheet) {
resolve()
return
}
link.addEventListener("load", () => resolve(), { once: true })
link.addEventListener("error", () => resolve(), { once: true })
})
)
)
if (doc.fonts?.ready) {
await doc.fonts.ready
}
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
function removePrintIframe(): void {
document.getElementById(PRINT_IFRAME_ID)?.remove()
}
async function printStackInIframe(
stack: HTMLElement,
snapshot: DocsExportSnapshot
): Promise<void> {
removePrintIframe()
const iframe = document.createElement("iframe")
iframe.id = PRINT_IFRAME_ID
iframe.setAttribute("aria-hidden", "true")
iframe.style.cssText =
"position:fixed;right:0;bottom:0;width:0;height:0;border:0;opacity:0;pointer-events:none"
document.body.appendChild(iframe)
const doc = iframe.contentDocument
const win = iframe.contentWindow
if (!doc || !win) {
removePrintIframe()
throw new Error("Impossible de préparer la fenêtre d'impression")
}
copyDocumentStyles(doc)
const printStyle = doc.createElement("style")
printStyle.textContent = buildDynamicPrintStyles(
snapshot.pageLayout,
snapshot.paragraphStyles
)
doc.head.appendChild(printStyle)
doc.documentElement.classList.add("docs-printing")
const root = doc.createElement("div")
root.className = "docs-print-root"
root.appendChild(stack.cloneNode(true))
doc.body.replaceChildren(root)
await waitForDocumentReady(doc)
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
win.removeEventListener("afterprint", onAfterPrint)
removePrintIframe()
}
const onAfterPrint = () => {
cleanup()
resolve()
}
win.addEventListener("afterprint", onAfterPrint)
try {
win.focus()
win.print()
} catch (error) {
cleanup()
reject(error)
return
}
window.setTimeout(() => {
if (document.getElementById(PRINT_IFRAME_ID)) {
cleanup()
resolve()
}
}, 2000)
})
}
async function waitForPrintLayout(): Promise<void> {
if (typeof document !== "undefined" && document.fonts?.ready) {
await document.fonts.ready
@ -56,9 +315,17 @@ export async function prepareDocsPrintEnvironment(
}
const removeStyles = injectPrintStyles(snapshot.pageLayout, snapshot.paragraphStyles)
const stack = snapshot.getPageStackElement()
const restoreStackLayout =
stack != null
? adjustPageStackForPrint(stack, snapshot.pageLayout, snapshot.pageCount)
: () => {}
await waitForPrintLayout()
return () => {
restoreStackLayout()
root.classList.remove("docs-printing", "docs-pdf-capture")
removeStyles()
}
@ -67,18 +334,15 @@ export async function prepareDocsPrintEnvironment(
export async function printDocsDocument(snapshot: DocsExportSnapshot): Promise<void> {
const cleanup = await prepareDocsPrintEnvironment(snapshot, "print")
const onAfterPrint = () => {
cleanup()
window.removeEventListener("afterprint", onAfterPrint)
}
window.addEventListener("afterprint", onAfterPrint)
window.print()
// Fallback cleanup if afterprint never fires (some browsers)
window.setTimeout(() => {
if (document.documentElement.classList.contains("docs-printing")) {
cleanup()
try {
const stack = snapshot.getPageStackElement()
if (!stack) {
window.print()
return
}
}, 2000)
await printStackInIframe(stack, snapshot)
} finally {
cleanup()
}
}

View File

@ -0,0 +1,97 @@
import type { Metadata } from "next"
import { SUITE_TITLE_SEP } from "@/lib/suite/page-metadata"
export type DriveEditorKind = "docs" | "sheets" | "presentation" | "draw"
export const DRIVE_EDITOR_LABELS: Record<DriveEditorKind, string> = {
docs: "UltiDocs",
sheets: "Sheets",
presentation: "Presentation",
draw: "Draw",
}
export const DRIVE_EDITOR_FAVICONS: Record<DriveEditorKind, string> = {
docs: "/drive/ultidocs-mark.svg",
sheets: "/drive/ultisheets-mark.svg",
presentation: "/drive/ultislides-mark.svg",
draw: "/drive/ultidraw-mark.svg",
}
export function driveEditorDocumentTitle(
titleSegment: string,
kind: DriveEditorKind
): string {
const trimmed = titleSegment.trim()
const label = DRIVE_EDITOR_LABELS[kind]
if (!trimmed) return label
return `${trimmed}${SUITE_TITLE_SEP}${label}`
}
export function driveEditorPageMetadata(
kind: DriveEditorKind,
titleSegment?: string
): Metadata {
const label = DRIVE_EDITOR_LABELS[kind]
const favicon = DRIVE_EDITOR_FAVICONS[kind]
return {
title:
titleSegment !== undefined
? {
default: titleSegment,
template: `%s${SUITE_TITLE_SEP}${label}`,
}
: label,
icons: {
icon: [{ url: favicon, type: "image/svg+xml" }],
apple: [{ url: favicon, type: "image/svg+xml" }],
shortcut: favicon,
},
}
}
function iconLinks(): HTMLLinkElement[] {
return Array.from(
document.querySelectorAll<HTMLLinkElement>(
'link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]'
)
)
}
export function applyDriveEditorFavicon(kind: DriveEditorKind): () => void {
const href = DRIVE_EDITOR_FAVICONS[kind]
const links = iconLinks()
const previous = links.map((link) => ({
link,
href: link.getAttribute("href"),
type: link.getAttribute("type"),
}))
if (links.length === 0) {
const link = document.createElement("link")
link.rel = "icon"
link.type = "image/svg+xml"
link.href = href
document.head.appendChild(link)
return () => {
link.remove()
}
}
for (const link of links) {
link.href = href
link.type = "image/svg+xml"
}
return () => {
for (const { link, href: prevHref, type } of previous) {
if (prevHref == null) {
link.remove()
continue
}
link.href = prevHref
if (type) link.type = type
else link.removeAttribute("type")
}
}
}

View File

@ -120,3 +120,33 @@ export function isOnlyOfficeFile(file: {
if (mime && isOnlyOfficeMime(mime)) return true
return isOnlyOfficeExtension(fileExtension(file.name))
}
const ONLYOFFICE_CELL_SET = new Set<string>(ONLYOFFICE_CELL)
const ONLYOFFICE_SLIDE_SET = new Set<string>([...ONLYOFFICE_SLIDE, ...ONLYOFFICE_DIAGRAM])
export function resolveOnlyOfficeEditorKind(file: {
name: string
mime_type?: string
}): "sheets" | "presentation" {
const mime = (file.mime_type ?? "").toLowerCase()
const ext = fileExtension(file.name)
if (
mime.includes("spreadsheet") ||
mime.includes("excel") ||
ONLYOFFICE_CELL_SET.has(ext)
) {
return "sheets"
}
if (
mime.includes("presentation") ||
mime.includes("powerpoint") ||
mime.includes("visio") ||
ONLYOFFICE_SLIDE_SET.has(ext)
) {
return "presentation"
}
return "sheets"
}

View File

@ -1,16 +1,20 @@
"use client"
import { useEffect } from "react"
import { SUITE_TITLE_SEP } from "@/lib/suite/page-metadata"
import {
applyDriveEditorFavicon,
driveEditorDocumentTitle,
type DriveEditorKind,
} from "@/lib/drive/drive-editor-metadata"
export function useDriveDocumentTitle(titleSegment: string) {
export function useDriveDocumentTitle(titleSegment: string, kind: DriveEditorKind) {
useEffect(() => {
const trimmed = titleSegment.trim()
if (!trimmed) return
const previous = document.title
document.title = `${trimmed}${SUITE_TITLE_SEP}UltiDrive`
const previousTitle = document.title
document.title = driveEditorDocumentTitle(titleSegment, kind)
const restoreFavicon = applyDriveEditorFavicon(kind)
return () => {
document.title = previous
document.title = previousTitle
restoreFavicon()
}
}, [titleSegment])
}, [titleSegment, kind])
}

View File

@ -4,11 +4,15 @@ import { useLayoutEffect, useRef, useState } from "react"
const OVERFLOW_BUTTON_WIDTH = 36
export function useToolbarOverflow(itemCount: number) {
export function useToolbarOverflow(itemCount: number, reservedTrailingPx = 0) {
const containerRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLDivElement>(null)
const [visibleCount, setVisibleCount] = useState(itemCount)
useLayoutEffect(() => {
setVisibleCount(itemCount)
}, [itemCount])
useLayoutEffect(() => {
const container = containerRef.current
const measure = measureRef.current
@ -16,15 +20,24 @@ export function useToolbarOverflow(itemCount: number) {
const recalculate = () => {
const children = Array.from(measure.children) as HTMLElement[]
if (children.length === 0) return
if (children.length === 0) {
setVisibleCount(0)
return
}
const containerWidth = container.clientWidth
if (containerWidth <= 0) return
const totalWidth = children.reduce((sum, child) => sum + child.offsetWidth, 0)
if (totalWidth <= container.clientWidth) {
const trailingReserve = reservedTrailingPx
const maxWithoutOverflow = containerWidth - trailingReserve
if (totalWidth <= maxWithoutOverflow) {
setVisibleCount(children.length)
return
}
const available = container.clientWidth - OVERFLOW_BUTTON_WIDTH
const available = containerWidth - OVERFLOW_BUTTON_WIDTH - trailingReserve
let used = 0
let fit = 0
@ -39,10 +52,22 @@ export function useToolbarOverflow(itemCount: number) {
}
recalculate()
const ro = new ResizeObserver(recalculate)
ro.observe(container)
return () => ro.disconnect()
}, [itemCount])
ro.observe(measure)
for (const child of measure.children) {
ro.observe(child)
}
const mo = new MutationObserver(recalculate)
mo.observe(measure, { childList: true })
return () => {
ro.disconnect()
mo.disconnect()
}
}, [itemCount, reservedTrailingPx])
const hasOverflow = visibleCount < itemCount

View File

@ -0,0 +1,7 @@
<!-- Keep in sync with lib/drive/docs-logo-paths.ts + DocsLogoIcon -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="UltiDocs">
<path fill="#4285F4" d="M8 18h8v-2H8zm0-4h8v-2H8zm-2 8q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h8l6 6v12q0 .825-.587 1.413T18 22z"/>
<path fill="#ffffff" d="M8 18h8v-2H8z"/>
<path fill="#ffffff" d="M8 14h8v-2H8z"/>
<path fill="#ffffff" d="M13 7h5l-5-5z" transform="translate(0 1)"/>
</svg>

After

Width:  |  Height:  |  Size: 469 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" role="img" aria-label="UltiDraw">
<rect width="40" height="40" rx="8" fill="#6965DB"/>
<path fill="none" stroke="#ffffff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" d="M11 27c4-8 8-12 12-14 2 4 2 8 0 12-4 2-8 2-12 2z"/>
<path fill="none" stroke="#FFC107" stroke-width="2.5" stroke-linecap="round" d="M24 13l5-4"/>
</svg>

After

Width:  |  Height:  |  Size: 410 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" role="img" aria-label="UltiSheets">
<path fill="#0F9D58" d="M8 6h18l8 8v22q0 1.65-1.18 2.83T30 40H10q-1.65 0-2.83-1.17T6 36V8q0-1.65 1.17-2.83T10 6z"/>
<path fill="#ffffff" d="M26 6v8h8z" opacity="0.95"/>
<path fill="#ffffff" d="M12 18h16v2H12zm0 6h16v2H12zm0 6h11v2H12z"/>
</svg>

After

Width:  |  Height:  |  Size: 347 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" role="img" aria-label="UltiPresentation">
<path fill="#F4B400" d="M8 6h18l8 8v22q0 1.65-1.18 2.83T30 40H10q-1.65 0-2.83-1.17T6 36V8q0-1.65 1.17-2.83T10 6z"/>
<path fill="#ffffff" d="M26 6v8h8z" opacity="0.95"/>
<rect x="12" y="17" width="16" height="11" rx="1.5" fill="#ffffff"/>
<path fill="#F4B400" d="M20 31 12 36h16z"/>
</svg>

After

Width:  |  Height:  |  Size: 399 B

View File

@ -1,66 +1,5 @@
/* UltiDocs print / PDF capture styles */
/* UltiDocs PDF capture styles (print uses a dedicated iframe) */
@media print {
.docs-printing header,
.docs-printing .docs-toolbar-shell,
.docs-printing .docs-rulers-left-rail,
.docs-printing .docs-status-bar,
.docs-printing .docs-hf-chrome,
.docs-printing .docs-graphic-floating-toolbar,
.docs-printing .docs-table-floating-toolbar,
.docs-printing .docs-graphic-options-sidebar,
.docs-printing .docs-loading-splash,
.docs-printing .docs-graphic-handle,
.docs-printing .docs-graphic-rotate-handle,
.docs-printing .docs-graphic-outline,
.docs-printing .docs-graphic-snap-guides,
.docs-printing .docs-ruler-margin-drag-tooltip {
display: none !important;
}
.docs-printing .ultidrive-docs-canvas {
overflow: visible !important;
background: white !important;
height: auto !important;
min-height: 0 !important;
}
.docs-printing [data-docs-page-stack] {
transform: none !important;
position: relative !important;
left: auto !important;
top: auto !important;
}
.docs-printing .ultidrive-docs-page {
box-shadow: none !important;
break-after: page;
page-break-after: always;
}
.docs-printing .ultidrive-docs-page:last-child {
break-after: auto;
page-break-after: auto;
}
.docs-printing .docs-hf-band--editing {
pointer-events: none;
}
.docs-printing .docs-hf-band .docs-region-editor-root {
border: none !important;
background: transparent !important;
}
.docs-printing #docs-page-graphic-layer-behind .docs-graphic-handle,
.docs-printing #docs-page-graphic-layer-front .docs-graphic-handle,
.docs-printing #docs-page-graphic-layer-behind .docs-graphic-outline,
.docs-printing #docs-page-graphic-layer-front .docs-graphic-outline {
display: none !important;
}
}
/* PDF capture uses the same layout rules without @media print */
.docs-printing.docs-pdf-capture header,
.docs-printing.docs-pdf-capture .docs-toolbar-shell,
.docs-printing.docs-pdf-capture .docs-rulers-left-rail,
@ -72,19 +11,53 @@
.docs-printing.docs-pdf-capture .docs-graphic-handle,
.docs-printing.docs-pdf-capture .docs-graphic-rotate-handle,
.docs-printing.docs-pdf-capture .docs-graphic-outline,
.docs-printing.docs-pdf-capture .docs-graphic-snap-guides {
.docs-printing.docs-pdf-capture .docs-graphic-snap-guides,
.docs-printing.docs-pdf-capture .docs-ruler-margin-drag-tooltip,
.docs-printing.docs-pdf-capture .docs-editor-workspace > div > .absolute {
display: none !important;
}
.docs-printing.docs-pdf-capture .docs-editor-workspace,
.docs-printing.docs-pdf-capture .docs-editor-workspace > div,
.docs-printing.docs-pdf-capture .docs-editor-workspace .flex-row {
padding: 0 !important;
margin: 0 !important;
overflow: visible !important;
}
.docs-printing.docs-pdf-capture .ultidrive-docs-canvas {
overflow: visible !important;
background: white !important;
height: auto !important;
min-height: 0 !important;
}
.docs-printing.docs-pdf-capture .ultidrive-docs-canvas > div,
.docs-printing.docs-pdf-capture .ultidrive-docs-canvas > div > div {
height: auto !important;
min-height: 0 !important;
padding: 0 !important;
margin: 0 !important;
}
.docs-printing.docs-pdf-capture [data-docs-page-stack] {
position: relative !important;
left: 0 !important;
top: 0 !important;
transform: none !important;
transform-origin: top left !important;
margin: 0 !important;
}
.docs-printing.docs-pdf-capture .ultidrive-docs-page {
box-shadow: none !important;
border: none !important;
}
.docs-printing.docs-pdf-capture .docs-body-margin-mask {
border-color: transparent !important;
}
.docs-printing.docs-pdf-capture .ultidrive-docs-editor-surface--dimmed .ProseMirror {
opacity: 1 !important;
}

View File

@ -476,6 +476,10 @@ html.dark .ultidrive-richtext-region-editor table th {
z-index: 24;
}
.docs-hf-lateral-border {
z-index: 25;
}
.docs-hf-chrome {
z-index: 27;
}
@ -965,6 +969,10 @@ html.dark .docs-menu-badge {
.docs-toolbar-shell {
padding: 0;
padding-inline: 8px 16px;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
.docs-toolbar-shell--collapsed {
@ -973,13 +981,13 @@ html.dark .docs-menu-badge {
.docs-toolbar-shell > .docs-toolbar {
padding-inline: 12px;
margin-inline-start: 8px;
margin-inline-end: 16px;
margin-bottom: 8px;
}
.docs-toolbar {
flex-wrap: nowrap;
min-width: 0;
max-width: 100%;
color: #202124;
background: #edf2fa;
border-radius: 9999px;

File diff suppressed because one or more lines are too long