ultisuite-client/lib/drive/docs-font-size.ts
2026-06-09 17:06:20 +02:00

158 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Editor } from "@tiptap/react"
import type { Mark, ResolvedPos } from "@tiptap/pm/model"
export const DOCS_FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72] as const
export const DOCS_DEFAULT_FONT_SIZE = 11
/** Matches .ultidrive-richtext-editor h1h4 in richtext-editor.css (rem × 16). */
const HEADING_FONT_SIZE_PX: Record<number, number> = {
1: 28,
2: 22,
3: 18,
4: 16,
}
let lastToolbarFontSize = DOCS_DEFAULT_FONT_SIZE
export type FontSizeToolbarState =
| { kind: "single"; size: number }
| { kind: "unset" }
function parseFontSizePx(raw: string | null | undefined): number | null {
if (!raw) return null
const parsed = Number.parseInt(String(raw).replace("px", ""), 10)
return Number.isFinite(parsed) ? parsed : null
}
function nearestFontSizeIndex(current: number): number {
let best = 0
let bestDistance = Number.POSITIVE_INFINITY
DOCS_FONT_SIZES.forEach((size, index) => {
const distance = Math.abs(size - current)
if (distance < bestDistance) {
bestDistance = distance
best = index
}
})
return best
}
function textStyleFontSizePx(
marks: ReadonlyArray<{ type: { name: string }; attrs: Record<string, unknown> }>
): number | null {
const mark = marks.find((m) => m.type.name === "textStyle")
return parseFontSizePx(mark?.attrs.fontSize as string | undefined)
}
function headingLevelAt($pos: ResolvedPos): number | null {
for (let depth = $pos.depth; depth > 0; depth--) {
const node = $pos.node(depth)
if (node.type.name === "heading") {
return node.attrs.level as number
}
}
return null
}
function headingFontSizePx(level: number): number {
return HEADING_FONT_SIZE_PX[level] ?? DOCS_DEFAULT_FONT_SIZE
}
/** Effective size of existing text at a position (no storedMarks). */
function effectiveFontSizeAtPos($pos: ResolvedPos, marks: readonly Mark[]): number {
const marked = textStyleFontSizePx(marks)
if (marked != null) return marked
const level = headingLevelAt($pos)
if (level != null) return headingFontSizePx(level)
return DOCS_DEFAULT_FONT_SIZE
}
function resolveCursorFontSizePx(editor: Editor): number {
const { state } = editor
const { $from } = state.selection
if (state.storedMarks) {
const stored = textStyleFontSizePx(state.storedMarks)
if (stored != null) return stored
}
const atCursor = textStyleFontSizePx($from.marks())
if (atCursor != null) return atCursor
const level = headingLevelAt($from)
if (level != null) return headingFontSizePx(level)
// Empty block: show last toolbar pick (typing size). Existing body text → default.
if ($from.parent.content.size === 0) return lastToolbarFontSize
return DOCS_DEFAULT_FONT_SIZE
}
function collectEffectiveSizesInRange(editor: Editor, from: number, to: number): Set<number> {
const sizes = new Set<number>()
const { state } = editor
state.doc.nodesBetween(from, to, (node, pos) => {
if (!node.isText || !node.text) return
const nodeStart = pos
const nodeEnd = pos + node.text.length
const start = Math.max(from, nodeStart)
const end = Math.min(to, nodeEnd)
if (start >= end) return
sizes.add(effectiveFontSizeAtPos(state.doc.resolve(start), node.marks))
})
return sizes
}
export function readFontSizeToolbarState(editor: Editor): FontSizeToolbarState {
const { from, to, empty } = editor.state.selection
if (empty) {
return { kind: "single", size: resolveCursorFontSizePx(editor) }
}
const sizes = collectEffectiveSizesInRange(editor, from, to)
if (sizes.size === 0) {
return { kind: "single", size: resolveCursorFontSizePx(editor) }
}
if (sizes.size === 1) {
return { kind: "single", size: [...sizes][0]! }
}
return { kind: "unset" }
}
export function readFontSizePxForStep(editor: Editor): number {
const state = readFontSizeToolbarState(editor)
return state.kind === "single" ? state.size : DOCS_DEFAULT_FONT_SIZE
}
export function applyFontSizePx(editor: Editor, size: number) {
lastToolbarFontSize = size
editor.chain().focus().setFontSize(`${size}px`).run()
}
export function stepFontSizePx(editor: Editor, direction: -1 | 1) {
const idx = nearestFontSizeIndex(readFontSizePxForStep(editor))
const nextIdx = Math.min(
DOCS_FONT_SIZES.length - 1,
Math.max(0, idx + direction)
)
applyFontSizePx(editor, DOCS_FONT_SIZES[nextIdx]!)
}
export function canStepFontSizePx(editor: Editor, direction: -1 | 1): boolean {
const idx = nearestFontSizeIndex(readFontSizePxForStep(editor))
if (direction < 0) return idx > 0
return idx < DOCS_FONT_SIZES.length - 1
}
/** @deprecated Use readFontSizeToolbarState */
export function readFontSizePx(editor: Editor): number {
const state = readFontSizeToolbarState(editor)
return state.kind === "single" ? state.size : DOCS_DEFAULT_FONT_SIZE
}