170 lines
5.6 KiB
TypeScript
170 lines
5.6 KiB
TypeScript
import type { Editor } from "@tiptap/react"
|
|
import type { DocParagraphStyleDefinition } from "@/lib/drive/docs-paragraph-styles"
|
|
import {
|
|
inferParagraphStyleIdFromEditorState,
|
|
normalizeParagraphStyleId,
|
|
resolveParagraphStyleDefinition,
|
|
} from "@/lib/drive/docs-paragraph-styles"
|
|
import { getActiveParagraphStyleCatalog } from "@/lib/drive/docs-paragraph-style-bridge"
|
|
import { fontFamilyStackForName } from "@/lib/drive/docs-font-family"
|
|
|
|
type BlockNodeType = "paragraph" | "heading"
|
|
|
|
function blockNodeType(def: DocParagraphStyleDefinition): BlockNodeType {
|
|
return def.blockType === "heading" && def.level ? "heading" : "paragraph"
|
|
}
|
|
|
|
function applyRunMarksToRange(
|
|
editor: Editor,
|
|
from: number,
|
|
to: number,
|
|
def: DocParagraphStyleDefinition
|
|
) {
|
|
if (from >= to) return
|
|
|
|
let chain = editor.chain().setTextSelection({ from, to })
|
|
|
|
const stack = def.fontFamily
|
|
? def.fontFamily.includes(",")
|
|
? def.fontFamily
|
|
: fontFamilyStackForName(def.fontFamily)
|
|
: null
|
|
|
|
if (stack) chain = chain.setFontFamily(stack)
|
|
else chain = chain.unsetFontFamily()
|
|
|
|
if (def.fontSizePx != null) chain = chain.setFontSize(`${def.fontSizePx}px`)
|
|
else chain = chain.unsetFontSize()
|
|
|
|
if (def.bold) chain = chain.setMark("bold")
|
|
else chain = chain.unsetMark("bold")
|
|
|
|
if (def.italic) chain = chain.setMark("italic")
|
|
else chain = chain.unsetMark("italic")
|
|
|
|
if (def.underline) chain = chain.setMark("underline")
|
|
else chain = chain.unsetMark("underline")
|
|
|
|
if (def.color) chain = chain.setColor(def.color)
|
|
else chain = chain.unsetColor()
|
|
|
|
chain.run()
|
|
}
|
|
|
|
function forEachSelectedBlock(
|
|
editor: Editor,
|
|
fn: (from: number, to: number, nodeType: BlockNodeType) => void
|
|
) {
|
|
const { state } = editor
|
|
const { from, to } = state.selection
|
|
|
|
state.doc.nodesBetween(from, to, (node, pos) => {
|
|
if (node.type.name !== "paragraph" && node.type.name !== "heading") return
|
|
const start = pos
|
|
const end = pos + node.content.size
|
|
fn(start + 1, end + 1, node.type.name as BlockNodeType)
|
|
return false
|
|
})
|
|
}
|
|
|
|
export function applyParagraphStyleDefinition(
|
|
editor: Editor,
|
|
def: DocParagraphStyleDefinition
|
|
) {
|
|
const nodeType = blockNodeType(def)
|
|
const chain = editor.chain().focus() as ReturnType<Editor["chain"]> & {
|
|
setParagraph: () => ReturnType<Editor["chain"]>
|
|
setHeading: (attrs: { level: number }) => ReturnType<Editor["chain"]>
|
|
updateAttributes: (
|
|
type: string,
|
|
attrs: Record<string, unknown>
|
|
) => ReturnType<Editor["chain"]>
|
|
setTextAlign: (align: string) => ReturnType<Editor["chain"]>
|
|
}
|
|
|
|
if (nodeType === "heading" && def.level) {
|
|
chain.setHeading({ level: def.level })
|
|
} else {
|
|
chain.setParagraph()
|
|
}
|
|
|
|
chain.updateAttributes(nodeType, {
|
|
styleId: def.id,
|
|
})
|
|
|
|
if (def.textAlign) chain.setTextAlign(def.textAlign)
|
|
chain.run()
|
|
|
|
forEachSelectedBlock(editor, (blockFrom, blockTo) => {
|
|
applyRunMarksToRange(editor, blockFrom, blockTo, def)
|
|
})
|
|
|
|
if (editor.state.selection.empty) {
|
|
const { $from } = editor.state.selection
|
|
if ($from.parent.type.name === "paragraph" || $from.parent.type.name === "heading") {
|
|
applyRunMarksToRange(editor, $from.start(), $from.end(), def)
|
|
}
|
|
}
|
|
}
|
|
|
|
export function applyDocsParagraphStyleById(editor: Editor, styleId: string) {
|
|
const catalog = getActiveParagraphStyleCatalog()
|
|
const def = resolveParagraphStyleDefinition(catalog, styleId)
|
|
if (!def) return false
|
|
applyParagraphStyleDefinition(editor, def)
|
|
return true
|
|
}
|
|
|
|
export function extractParagraphStyleFromSelection(
|
|
editor: Editor,
|
|
styleId: string
|
|
): DocParagraphStyleDefinition | null {
|
|
const catalog = getActiveParagraphStyleCatalog()
|
|
const existing =
|
|
resolveParagraphStyleDefinition(catalog, styleId) ??
|
|
resolveParagraphStyleDefinition(catalog, "normal")
|
|
if (!existing) return null
|
|
|
|
const { $from } = editor.state.selection
|
|
const node = $from.parent
|
|
if (node.type.name !== "paragraph" && node.type.name !== "heading") return null
|
|
|
|
const attrs = node.attrs as { styleId?: string; textAlign?: string }
|
|
const marks = editor.state.storedMarks ?? $from.marks()
|
|
|
|
const textStyle = marks.find((m) => m.type.name === "textStyle")
|
|
const fontFamily = (textStyle?.attrs.fontFamily as string | undefined) ?? existing.fontFamily
|
|
const fontSizeRaw = textStyle?.attrs.fontSize as string | undefined
|
|
const fontSizePx = fontSizeRaw ? Number.parseFloat(fontSizeRaw) : existing.fontSizePx
|
|
|
|
return {
|
|
...existing,
|
|
id: normalizeParagraphStyleId(styleId),
|
|
name: existing.name,
|
|
scope: existing.scope,
|
|
blockType: node.type.name === "heading" ? "heading" : "paragraph",
|
|
level: node.type.name === "heading" ? (node.attrs.level as number) : undefined,
|
|
fontFamily,
|
|
fontSizePx: Number.isFinite(fontSizePx) ? fontSizePx : existing.fontSizePx,
|
|
bold: marks.some((m) => m.type.name === "bold"),
|
|
italic: marks.some((m) => m.type.name === "italic"),
|
|
underline: marks.some((m) => m.type.name === "underline"),
|
|
color: (marks.find((m) => m.type.name === "textStyle")?.attrs.color as string) ?? existing.color,
|
|
textAlign:
|
|
(attrs.textAlign as DocParagraphStyleDefinition["textAlign"]) ?? existing.textAlign,
|
|
}
|
|
}
|
|
|
|
export function readActiveParagraphStyleId(editor: Editor): string {
|
|
const headingAttrs = editor.getAttributes("heading")
|
|
if (headingAttrs.styleId) return normalizeParagraphStyleId(String(headingAttrs.styleId))
|
|
|
|
const paragraphAttrs = editor.getAttributes("paragraph")
|
|
if (paragraphAttrs.styleId) return normalizeParagraphStyleId(String(paragraphAttrs.styleId))
|
|
|
|
return inferParagraphStyleIdFromEditorState({
|
|
isHeading: editor.isActive("heading"),
|
|
headingLevel: headingAttrs.level as number | undefined,
|
|
})
|
|
}
|