641 lines
17 KiB
TypeScript
641 lines
17 KiB
TypeScript
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,
|
|
type Editor,
|
|
type NodeWithPos,
|
|
} from "@tiptap/react"
|
|
|
|
export const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
|
|
|
export const MAC_SYMBOLS: Record<string, string> = {
|
|
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<string> => {
|
|
// 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<ProtocolOptions | string>
|
|
|
|
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<A extends string = string, V = unknown>(
|
|
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<string, unknown>)[
|
|
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<string, unknown> = { ...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<number>()
|
|
|
|
doc.nodesBetween(from, to, (node, pos) => {
|
|
if (!node.isBlock) return
|
|
|
|
if (!seen.has(pos)) {
|
|
seen.add(pos)
|
|
blocks.push(node)
|
|
}
|
|
|
|
return false
|
|
})
|
|
|
|
return blocks
|
|
}
|