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 | "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(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 ): 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 ): 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 } }