import type { Node as PMNode } from "@tiptap/pm/model" import type { Transaction } from "@tiptap/pm/state" import { AllSelection, NodeSelection, Selection, TextSelection, } from "@tiptap/pm/state" import { cellAround, CellSelection } from "@tiptap/pm/tables" import { findParentNodeClosestToPos } from "@tiptap/react" import type {Editor, NodeWithPos} from "@tiptap/react"; export const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB export const MAC_SYMBOLS: Record = { mod: "⌘", command: "⌘", meta: "⌘", ctrl: "⌃", control: "⌃", alt: "⌥", option: "⌥", shift: "⇧", backspace: "Del", delete: "⌦", enter: "⏎", escape: "⎋", capslock: "⇪", } as const export const SR_ONLY = { position: "absolute", width: "1px", height: "1px", padding: 0, margin: "-1px", overflow: "hidden", clip: "rect(0, 0, 0, 0)", whiteSpace: "nowrap", borderWidth: 0, } as const export function cn( ...classes: (string | boolean | undefined | null)[] ): string { return classes.filter(Boolean).join(" ") } /** * Determines if the current platform is macOS * @returns boolean indicating if the current platform is Mac */ export function isMac(): boolean { return ( typeof navigator !== "undefined" && navigator.platform.toLowerCase().includes("mac") ) } /** * Formats a shortcut key based on the platform (Mac or non-Mac) * @param key - The key to format (e.g., "ctrl", "alt", "shift") * @param isMac - Boolean indicating if the platform is Mac * @param capitalize - Whether to capitalize the key (default: true) * @returns Formatted shortcut key symbol */ export const formatShortcutKey = ( key: string, isMac: boolean, capitalize: boolean = true ) => { if (isMac) { const lowerKey = key.toLowerCase() return MAC_SYMBOLS[lowerKey] || (capitalize ? key.toUpperCase() : key) } return capitalize ? key.charAt(0).toUpperCase() + key.slice(1) : key } /** * Parses a shortcut key string into an array of formatted key symbols * @param shortcutKeys - The string of shortcut keys (e.g., "ctrl-alt-shift") * @param delimiter - The delimiter used to split the keys (default: "-") * @param capitalize - Whether to capitalize the keys (default: true) * @returns Array of formatted shortcut key symbols */ export const parseShortcutKeys = (props: { shortcutKeys: string | undefined delimiter?: string capitalize?: boolean }) => { const { shortcutKeys, delimiter = "+", capitalize = true } = props if (!shortcutKeys) { return [] } return shortcutKeys .split(delimiter) .map((key) => key.trim()) .map((key) => formatShortcutKey(key, isMac(), capitalize)) } /** * Checks if a mark exists in the editor schema * @param markName - The name of the mark to check * @param editor - The editor instance * @returns boolean indicating if the mark exists in the schema */ export const isMarkInSchema = ( markName: string, editor: Editor | null ): boolean => { if (!editor?.schema) { return false } return editor.schema.spec.marks.get(markName) !== undefined } /** * Checks if a node exists in the editor schema * @param nodeName - The name of the node to check * @param editor - The editor instance * @returns boolean indicating if the node exists in the schema */ export const isNodeInSchema = ( nodeName: string, editor: Editor | null ): boolean => { if (!editor?.schema) { return false } return editor.schema.spec.nodes.get(nodeName) !== undefined } /** * Moves the focus to the next node in the editor * @param editor - The editor instance * @returns boolean indicating if the focus was moved */ export function focusNextNode(editor: Editor) { const { state, view } = editor const { doc, selection } = state const nextSel = Selection.findFrom(selection.$to, 1, true) if (nextSel) { view.dispatch(state.tr.setSelection(nextSel).scrollIntoView()) return true } const paragraphType = state.schema.nodes.paragraph if (!paragraphType) { console.warn("No paragraph node type found in schema.") return false } const end = doc.content.size const para = paragraphType.create() let tr = state.tr.insert(end, para) // Place the selection inside the new paragraph const $inside = tr.doc.resolve(end + 1) tr = tr.setSelection(TextSelection.near($inside)).scrollIntoView() view.dispatch(tr) return true } /** * Checks if a value is a valid number (not null, undefined, or NaN) * @param value - The value to check * @returns boolean indicating if the value is a valid number */ export function isValidPosition(pos: number | null | undefined): pos is number { return typeof pos === "number" && pos >= 0 } /** * Checks if one or more extensions are registered in the Tiptap editor. * @param editor - The Tiptap editor instance * @param extensionNames - A single extension name or an array of names to check * @returns True if at least one of the extensions is available, false otherwise */ export function isExtensionAvailable( editor: Editor | null, extensionNames: string | string[] ): boolean { if (!editor) { return false } const names = Array.isArray(extensionNames) ? extensionNames : [extensionNames] const found = names.some((name) => editor.extensionManager.extensions.some((ext) => ext.name === name) ) if (!found) { console.warn( `None of the extensions [${names.join(", ")}] were found in the editor schema. Ensure they are included in the editor configuration.` ) } return found } /** * Finds a node at the specified position with error handling * @param editor The Tiptap editor instance * @param position The position in the document to find the node * @returns The node at the specified position, or null if not found */ export function findNodeAtPosition(editor: Editor, position: number) { try { const node = editor.state.doc.nodeAt(position) if (!node) { console.warn(`No node found at position ${position}`) return null } return node } catch (error) { console.error(`Error getting node at position ${position}:`, error) return null } } /** * Finds the position and instance of a node in the document * @param props Object containing editor, node (optional), and nodePos (optional) * @param props.editor The Tiptap editor instance * @param props.node The node to find (optional if nodePos is provided) * @param props.nodePos The position of the node to find (optional if node is provided) * @returns An object with the position and node, or null if not found */ export function findNodePosition(props: { editor: Editor | null node?: PMNode | null nodePos?: number | null }): { pos: number; node: PMNode } | null { const { editor, node, nodePos } = props if (!editor || !editor.state?.doc) { return null } // Zero is valid position const hasValidNode = node !== undefined && node !== null const hasValidPos = isValidPosition(nodePos) if (!hasValidNode && !hasValidPos) { return null } // First search for the node in the document if we have a node if (hasValidNode) { let foundPos = -1 let foundNode: PMNode | null = null editor.state.doc.descendants((currentNode, pos) => { // TODO: Needed? // if (currentNode.type && currentNode.type.name === node!.type.name) { if (currentNode === node) { foundPos = pos foundNode = currentNode return false } return true }) if (foundPos !== -1 && foundNode !== null) { return { pos: foundPos, node: foundNode } } } // If we have a valid position, use findNodeAtPosition if (hasValidPos) { const nodeAtPos = findNodeAtPosition(editor, nodePos!) if (nodeAtPos) { return { pos: nodePos!, node: nodeAtPos } } } return null } /** * Determines whether the current selection contains a node whose type matches * any of the provided node type names. * @param editor Tiptap editor instance * @param nodeTypeNames List of node type names to match against * @param checkAncestorNodes Whether to check ancestor node types up the depth chain */ export function isNodeTypeSelected( editor: Editor | null, nodeTypeNames: string[] = [], checkAncestorNodes: boolean = false ): boolean { if (!editor || !editor.state.selection) { return false } const { selection } = editor.state if (selection.empty) { return false } // Direct node selection check if (selection instanceof NodeSelection) { const selectedNode = selection.node return selectedNode ? nodeTypeNames.includes(selectedNode.type.name) : false } // Depth-based ancestor node check if (checkAncestorNodes) { const { $from } = selection for (let depth = $from.depth; depth > 0; depth--) { const ancestorNode = $from.node(depth) if (nodeTypeNames.includes(ancestorNode.type.name)) { return true } } } return false } /** * Check whether the current selection is fully within nodes * whose type names are in the provided `types` list. * * - NodeSelection → checks the selected node. * - Text/AllSelection → ensures all textblocks within [from, to) are allowed. */ export function selectionWithinConvertibleTypes( editor: Editor, types: string[] = [] ): boolean { if (!editor || types.length === 0) { return false } const { state } = editor const { selection } = state const allowed = new Set(types) if (selection instanceof NodeSelection) { const nodeType = selection.node?.type?.name return !!nodeType && allowed.has(nodeType) } if (selection instanceof TextSelection || selection instanceof AllSelection) { let valid = true state.doc.nodesBetween(selection.from, selection.to, (node) => { if (node.isTextblock && !allowed.has(node.type.name)) { valid = false return false // stop early } return valid }) return valid } return false } /** * Handles image upload with progress tracking and abort capability * @param file The file to upload * @param onProgress Optional callback for tracking upload progress * @param abortSignal Optional AbortSignal for cancelling the upload * @returns Promise resolving to the URL of the uploaded image */ export const handleImageUpload = async ( file: File, onProgress?: (event: { progress: number }) => void, abortSignal?: AbortSignal ): Promise => { // Validate file if (!file) { throw new Error("No file provided") } if (file.size > MAX_FILE_SIZE) { throw new Error( `File size exceeds maximum allowed (${MAX_FILE_SIZE / (1024 * 1024)}MB)` ) } // For demo/testing: Simulate upload progress. In production, replace the following code // with your own upload implementation. for (let progress = 0; progress <= 100; progress += 10) { if (abortSignal?.aborted) { throw new Error("Upload cancelled") } await new Promise((resolve) => setTimeout(resolve, 500)) onProgress?.({ progress }) } return "/images/tiptap-ui-placeholder-image.jpg" } type ProtocolOptions = { /** * The protocol scheme to be registered. * @default ''' * @example 'ftp' * @example 'git' */ scheme: string /** * If enabled, it allows optional slashes after the protocol. * @default false * @example true */ optionalSlashes?: boolean } type ProtocolConfig = Array const ATTR_WHITESPACE = // eslint-disable-next-line no-control-regex /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g export function isAllowedUri( uri: string | undefined, protocols?: ProtocolConfig ) { const allowedProtocols: string[] = [ "http", "https", "ftp", "ftps", "mailto", "tel", "callto", "sms", "cid", "xmpp", ] if (protocols) { protocols.forEach((protocol) => { const nextProtocol = typeof protocol === "string" ? protocol : protocol.scheme if (nextProtocol) { allowedProtocols.push(nextProtocol) } }) } return ( !uri || uri.replace(ATTR_WHITESPACE, "").match( new RegExp( // eslint-disable-next-line no-useless-escape `^(?:(?:${allowedProtocols.join("|")}):|[^a-z]|[a-z0-9+.\-]+(?:[^a-z+.\-:]|$))`, "i" ) ) ) } export function sanitizeUrl( inputUrl: string, baseUrl: string, protocols?: ProtocolConfig ): string { try { const url = new URL(inputUrl, baseUrl) if (isAllowedUri(url.href, protocols)) { return url.href } } catch { // If URL creation fails, it's considered invalid } return "#" } /** * Update a single attribute on multiple nodes. * * @param tr - The transaction to mutate * @param targets - Array of { node, pos } * @param attrName - Attribute key to update * @param next - New value OR updater function receiving previous value * Pass `undefined` to remove the attribute. * @returns true if at least one node was updated, false otherwise */ export function updateNodesAttr( tr: Transaction, targets: readonly NodeWithPos[], attrName: A, next: V | ((prev: V | undefined) => V | undefined) ): boolean { if (!targets.length) { return false } let changed = false for (const { pos } of targets) { // Always re-read from the transaction's current doc const currentNode = tr.doc.nodeAt(pos) if (!currentNode) { continue } const prevValue = (currentNode.attrs as Record)[ attrName ] as V | undefined const resolvedNext = typeof next === "function" ? (next as (p: V | undefined) => V | undefined)(prevValue) : next if (prevValue === resolvedNext) { continue } const nextAttrs: Record = { ...currentNode.attrs } if (resolvedNext === undefined) { // Remove the key entirely instead of setting null delete nextAttrs[attrName] } else { nextAttrs[attrName] = resolvedNext } tr.setNodeMarkup(pos, undefined, nextAttrs) changed = true } return changed } /** * Selects the entire content of the current block node if the selection is empty. * If the selection is not empty, it does nothing. * @param editor The Tiptap editor instance */ export function selectCurrentBlockContent(editor: Editor) { const { selection, doc } = editor.state if (!selection.empty) { return } const $pos = selection.$from let blockNode = null let blockPos = -1 for (let depth = $pos.depth; depth >= 0; depth--) { const node = $pos.node(depth) const pos = $pos.start(depth) if (node.isBlock && node.textContent.trim()) { blockNode = node blockPos = pos break } } if (blockNode && blockPos >= 0) { const from = blockPos const to = blockPos + blockNode.nodeSize - 2 // -2 to exclude the closing tag if (from < to) { const $from = doc.resolve(from) const $to = doc.resolve(to) const newSelection = TextSelection.between($from, $to, 1) if (newSelection && !selection.eq(newSelection)) { editor.view.dispatch(editor.state.tr.setSelection(newSelection)) } } } } /** * Retrieves all nodes of specified types from the current selection. * @param selection The current editor selection * @param allowedNodeTypes An array of node type names to look for (e.g., ["image", "table"]) * @returns An array of objects containing the node and its position */ export function getSelectedNodesOfType( selection: Selection, allowedNodeTypes: string[] ): NodeWithPos[] { const results: NodeWithPos[] = [] const allowed = new Set(allowedNodeTypes) if (selection instanceof CellSelection) { selection.forEachCell((node: PMNode, pos: number) => { if (allowed.has(node.type.name)) { results.push({ node, pos }) } }) return results } if (selection instanceof NodeSelection) { const { node, from: pos } = selection if (node && allowed.has(node.type.name)) { results.push({ node, pos }) } return results } const { $anchor } = selection const cell = cellAround($anchor) if (cell) { const cellNode = selection.$anchor.doc.nodeAt(cell.pos) if (cellNode && allowed.has(cellNode.type.name)) { results.push({ node: cellNode, pos: cell.pos }) return results } } // Fallback: find parent nodes of allowed types const parentNode = findParentNodeClosestToPos($anchor, (node) => allowed.has(node.type.name) ) if (parentNode) { results.push({ node: parentNode.node, pos: parentNode.pos }) } return results } /** * Clamps a value between min and max bounds */ export function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(value, max)) } export function getSelectedBlockNodes(editor: Editor): PMNode[] { const { doc } = editor.state const { from, to } = editor.state.selection const blocks: PMNode[] = [] const seen = new Set() doc.nodesBetween(from, to, (node, pos) => { if (!node.isBlock) { return } if (!seen.has(pos)) { seen.add(pos) blocks.push(node) } return false }) return blocks }