158 lines
4.7 KiB
TypeScript
158 lines
4.7 KiB
TypeScript
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 h1–h4 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
|
||
}
|