ultisuite-client/lib/drive/docs-line-spacing-actions.ts
R3D347HR4Y 303b2b1074
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
wow
2026-06-11 01:22:40 +02:00

228 lines
8.4 KiB
TypeScript

import type { Editor } from "@tiptap/react"
import { getDocsBlockTargets } from "@/lib/drive/docs-block-targets"
import { getActiveParagraphStyleCatalog } from "@/lib/drive/docs-paragraph-style-bridge"
import {
resolveParagraphStyleDefinition,
} from "@/lib/drive/docs-paragraph-styles"
import { readActiveParagraphStyleId } from "@/lib/drive/docs-paragraph-style-apply"
import {
DOCS_DEFAULT_LINE_HEIGHT,
DOCS_DEFAULT_PARAGRAPH_SPACING,
DOCS_QUICK_PARAGRAPH_SPACE_PT,
type DocsParagraphSpacingAttrs,
lineHeightPresetId,
readParagraphSpacingAttrs,
} from "@/lib/drive/docs-line-spacing"
export type DocsParagraphSpacingState = {
lineHeight: number | null
lineHeightPresetId: ReturnType<typeof lineHeightPresetId> | "mixed"
spaceBeforePt: number | "mixed"
spaceAfterPt: number | "mixed"
hasSpaceBefore: boolean | "mixed"
hasSpaceAfter: boolean | "mixed"
keepWithNext: boolean | "mixed"
keepLinesTogether: boolean | "mixed"
preventWidowOrphan: boolean | "mixed"
pageBreakBefore: boolean | "mixed"
}
function mergeMixed<T>(values: T[]): T | "mixed" {
if (values.length === 0) return values[0] as T
const first = values[0]
return values.every((value) => value === first) ? first : "mixed"
}
function effectiveLineHeight(editor: Editor, attrs: DocsParagraphSpacingAttrs, styleId: string | null): number {
if (attrs.lineHeight != null) return attrs.lineHeight
if (styleId) {
const catalog = getActiveParagraphStyleCatalog()
const def = resolveParagraphStyleDefinition(catalog, styleId)
if (def?.lineHeight) return def.lineHeight
}
return DOCS_DEFAULT_LINE_HEIGHT
}
function readBlockSpacingStates(editor: Editor): Array<{
attrs: DocsParagraphSpacingAttrs
effectiveLineHeight: number
}> {
const { state } = editor
const targets = getDocsBlockTargets(state.doc, state.selection.from, state.selection.to)
return targets.map(({ node }) => {
const attrs = readParagraphSpacingAttrs(node)
const styleId = (node.attrs.styleId as string | null | undefined) ?? null
return {
attrs,
effectiveLineHeight: effectiveLineHeight(editor, attrs, styleId),
}
})
}
export function readDocsParagraphSpacingState(editor: Editor | null): DocsParagraphSpacingState {
if (!editor || editor.isDestroyed) {
return {
lineHeight: DOCS_DEFAULT_LINE_HEIGHT,
lineHeightPresetId: "1.15",
spaceBeforePt: 0,
spaceAfterPt: 0,
hasSpaceBefore: false,
hasSpaceAfter: false,
keepWithNext: false,
keepLinesTogether: false,
preventWidowOrphan: false,
pageBreakBefore: false,
}
}
const blocks = readBlockSpacingStates(editor)
if (blocks.length === 0) {
const styleId = readActiveParagraphStyleId(editor)
const catalog = getActiveParagraphStyleCatalog()
const def = resolveParagraphStyleDefinition(catalog, styleId)
const lineHeight = def?.lineHeight ?? DOCS_DEFAULT_LINE_HEIGHT
return {
lineHeight,
lineHeightPresetId: lineHeightPresetId(lineHeight),
spaceBeforePt: 0,
spaceAfterPt: 0,
hasSpaceBefore: false,
hasSpaceAfter: false,
keepWithNext: false,
keepLinesTogether: false,
preventWidowOrphan: false,
pageBreakBefore: false,
}
}
const effectiveHeights = blocks.map((block) => block.effectiveLineHeight)
const mergedHeight = mergeMixed(effectiveHeights)
const presetIds = effectiveHeights.map((value) => lineHeightPresetId(value))
const mergedPreset = mergeMixed(presetIds)
const spaceBefore = blocks.map((block) => block.attrs.spaceBeforePt)
const spaceAfter = blocks.map((block) => block.attrs.spaceAfterPt)
return {
lineHeight: mergedHeight === "mixed" ? null : mergedHeight,
lineHeightPresetId: mergedPreset,
spaceBeforePt: mergeMixed(spaceBefore),
spaceAfterPt: mergeMixed(spaceAfter),
hasSpaceBefore: mergeMixed(spaceBefore.map((value) => value >= DOCS_QUICK_PARAGRAPH_SPACE_PT)),
hasSpaceAfter: mergeMixed(spaceAfter.map((value) => value >= DOCS_QUICK_PARAGRAPH_SPACE_PT)),
keepWithNext: mergeMixed(blocks.map((block) => block.attrs.keepWithNext)),
keepLinesTogether: mergeMixed(blocks.map((block) => block.attrs.keepLinesTogether)),
preventWidowOrphan: mergeMixed(blocks.map((block) => block.attrs.preventWidowOrphan)),
pageBreakBefore: mergeMixed(blocks.map((block) => block.attrs.pageBreakBefore)),
}
}
function applyBlockSpacingPatch(
editor: Editor,
patch: Partial<DocsParagraphSpacingAttrs>
): boolean {
const { state } = editor
const targets = getDocsBlockTargets(state.doc, state.selection.from, state.selection.to)
if (targets.length === 0) return false
let tr = state.tr
let changed = false
for (const { pos, node } of targets) {
const current = readParagraphSpacingAttrs(node)
const next = { ...current, ...patch }
const same =
current.lineHeight === next.lineHeight &&
current.spaceBeforePt === next.spaceBeforePt &&
current.spaceAfterPt === next.spaceAfterPt &&
current.keepWithNext === next.keepWithNext &&
current.keepLinesTogether === next.keepLinesTogether &&
current.preventWidowOrphan === next.preventWidowOrphan &&
current.pageBreakBefore === next.pageBreakBefore
if (same) continue
tr = tr.setNodeMarkup(pos, undefined, { ...node.attrs, ...next })
changed = true
}
if (!changed) return false
editor.view.dispatch(tr.scrollIntoView())
return true
}
export function setDocsLineHeight(editor: Editor | null, lineHeight: number): boolean {
if (!editor || editor.isDestroyed) return false
return applyBlockSpacingPatch(editor, { lineHeight })
}
export function setDocsCustomSpacing(
editor: Editor | null,
input: Pick<DocsParagraphSpacingAttrs, "lineHeight" | "spaceBeforePt" | "spaceAfterPt">
): boolean {
if (!editor || editor.isDestroyed) return false
return applyBlockSpacingPatch(editor, input)
}
export function toggleDocsSpaceBefore(editor: Editor | null): boolean {
if (!editor || editor.isDestroyed) return false
const blocks = readBlockSpacingStates(editor)
if (blocks.length === 0) return false
const allHaveQuick = blocks.every(
(block) => block.attrs.spaceBeforePt >= DOCS_QUICK_PARAGRAPH_SPACE_PT
)
return applyBlockSpacingPatch(editor, {
spaceBeforePt: allHaveQuick ? 0 : DOCS_QUICK_PARAGRAPH_SPACE_PT,
})
}
export function toggleDocsSpaceAfter(editor: Editor | null): boolean {
if (!editor || editor.isDestroyed) return false
const blocks = readBlockSpacingStates(editor)
if (blocks.length === 0) return false
const allHaveQuick = blocks.every(
(block) => block.attrs.spaceAfterPt >= DOCS_QUICK_PARAGRAPH_SPACE_PT
)
return applyBlockSpacingPatch(editor, {
spaceAfterPt: allHaveQuick ? 0 : DOCS_QUICK_PARAGRAPH_SPACE_PT,
})
}
export function toggleDocsKeepWithNext(editor: Editor | null): boolean {
if (!editor || editor.isDestroyed) return false
const blocks = readBlockSpacingStates(editor)
if (blocks.length === 0) return false
const allOn = blocks.every((block) => block.attrs.keepWithNext)
return applyBlockSpacingPatch(editor, { keepWithNext: !allOn })
}
export function toggleDocsKeepLinesTogether(editor: Editor | null): boolean {
if (!editor || editor.isDestroyed) return false
const blocks = readBlockSpacingStates(editor)
if (blocks.length === 0) return false
const allOn = blocks.every((block) => block.attrs.keepLinesTogether)
return applyBlockSpacingPatch(editor, { keepLinesTogether: !allOn })
}
export function toggleDocsPreventWidowOrphan(editor: Editor | null): boolean {
if (!editor || editor.isDestroyed) return false
const blocks = readBlockSpacingStates(editor)
if (blocks.length === 0) return false
const allOn = blocks.every((block) => block.attrs.preventWidowOrphan)
return applyBlockSpacingPatch(editor, { preventWidowOrphan: !allOn })
}
export function toggleDocsPageBreakBefore(editor: Editor | null): boolean {
if (!editor || editor.isDestroyed) return false
const blocks = readBlockSpacingStates(editor)
if (blocks.length === 0) return false
const allOn = blocks.every((block) => block.attrs.pageBreakBefore)
return applyBlockSpacingPatch(editor, { pageBreakBefore: !allOn })
}
export function readDocsCustomSpacingDraft(editor: Editor | null): DocsParagraphSpacingAttrs {
if (!editor || editor.isDestroyed) return { ...DOCS_DEFAULT_PARAGRAPH_SPACING }
const blocks = readBlockSpacingStates(editor)
if (blocks.length === 0) return { ...DOCS_DEFAULT_PARAGRAPH_SPACING }
const first = blocks[0].attrs
return { ...DOCS_DEFAULT_PARAGRAPH_SPACING, ...first }
}