fix: resolve TypeScript errors in frontend build

This commit is contained in:
Hiro
2026-03-30 23:16:07 +00:00
parent b733306773
commit 24925e1acb
2941 changed files with 418042 additions and 49 deletions
+195
View File
@@ -0,0 +1,195 @@
import { Extension } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
export interface CharacterCountOptions {
/**
* The maximum number of characters that should be allowed. Defaults to `0`.
* @default null
* @example 180
*/
limit: number | null | undefined
/**
* The mode by which the size is calculated. If set to `textSize`, the textContent of the document is used.
* If set to `nodeSize`, the nodeSize of the document is used.
* @default 'textSize'
* @example 'textSize'
*/
mode: 'textSize' | 'nodeSize'
/**
* The text counter function to use. Defaults to a simple character count.
* @default (text) => text.length
* @example (text) => [...new Intl.Segmenter().segment(text)].length
*/
textCounter: (text: string) => number
/**
* The word counter function to use. Defaults to a simple word count.
* @default (text) => text.split(' ').filter(word => word !== '').length
* @example (text) => text.split(/\s+/).filter(word => word !== '').length
*/
wordCounter: (text: string) => number
}
export interface CharacterCountStorage {
/**
* Get the number of characters for the current document.
* @param options The options for the character count. (optional)
* @param options.node The node to get the characters from. Defaults to the current document.
* @param options.mode The mode by which the size is calculated. If set to `textSize`, the textContent of the document is used.
*/
characters: (options?: { node?: ProseMirrorNode; mode?: 'textSize' | 'nodeSize' }) => number
/**
* Get the number of words for the current document.
* @param options The options for the character count. (optional)
* @param options.node The node to get the words from. Defaults to the current document.
*/
words: (options?: { node?: ProseMirrorNode }) => number
}
declare module '@tiptap/core' {
interface Storage {
characterCount: CharacterCountStorage
}
}
/**
* This extension allows you to count the characters and words of your document.
* @see https://tiptap.dev/api/extensions/character-count
*/
export const CharacterCount = Extension.create<CharacterCountOptions, CharacterCountStorage>({
name: 'characterCount',
addOptions() {
return {
limit: null,
mode: 'textSize',
textCounter: text => text.length,
wordCounter: text => text.split(' ').filter(word => word !== '').length,
}
},
addStorage() {
return {
characters: () => 0,
words: () => 0,
}
},
onBeforeCreate() {
this.storage.characters = options => {
const node = options?.node || this.editor.state.doc
const mode = options?.mode || this.options.mode
if (mode === 'textSize') {
const text = node.textBetween(0, node.content.size, undefined, ' ')
return this.options.textCounter(text)
}
return node.nodeSize
}
this.storage.words = options => {
const node = options?.node || this.editor.state.doc
const text = node.textBetween(0, node.content.size, ' ', ' ')
return this.options.wordCounter(text)
}
},
addProseMirrorPlugins() {
let initialEvaluationDone = false
return [
new Plugin({
key: new PluginKey('characterCount'),
appendTransaction: (transactions, oldState, newState) => {
if (initialEvaluationDone) {
return
}
const limit = this.options.limit
if (limit === null || limit === undefined || limit === 0) {
initialEvaluationDone = true
return
}
const initialContentSize = this.storage.characters({ node: newState.doc })
if (initialContentSize > limit) {
const over = initialContentSize - limit
const from = 0
const to = over
console.warn(
`[CharacterCount] Initial content exceeded limit of ${limit} characters. Content was automatically trimmed.`,
)
const tr = newState.tr.deleteRange(from, to)
initialEvaluationDone = true
return tr
}
initialEvaluationDone = true
},
filterTransaction: (transaction, state) => {
const limit = this.options.limit
// Nothing has changed or no limit is defined. Ignore it.
if (!transaction.docChanged || limit === 0 || limit === null || limit === undefined) {
return true
}
const oldSize = this.storage.characters({ node: state.doc })
const newSize = this.storage.characters({ node: transaction.doc })
// Everything is in the limit. Good.
if (newSize <= limit) {
return true
}
// The limit has already been exceeded but will be reduced.
if (oldSize > limit && newSize > limit && newSize <= oldSize) {
return true
}
// The limit has already been exceeded and will be increased further.
if (oldSize > limit && newSize > limit && newSize > oldSize) {
return false
}
const isPaste = transaction.getMeta('paste')
// Block all exceeding transactions that were not pasted.
if (!isPaste) {
return false
}
// For pasted content, we try to remove the exceeding content.
const pos = transaction.selection.$head.pos
const over = newSize - limit
const from = pos - over
const to = pos
// Its probably a bad idea to mutate transactions within `filterTransaction`
// but for now this is working fine.
transaction.deleteRange(from, to)
// In some situations, the limit will continue to be exceeded after trimming.
// This happens e.g. when truncating within a complex node (e.g. table)
// and ProseMirror has to close this node again.
// If this is the case, we prevent the transaction completely.
const updatedSize = this.storage.characters({ node: transaction.doc })
if (updatedSize > limit) {
return false
}
return true
},
}),
]
},
})
+1
View File
@@ -0,0 +1 @@
export * from './character-count.js'
+47
View File
@@ -0,0 +1,47 @@
import { Extension } from '@tiptap/core'
import { dropCursor } from '@tiptap/pm/dropcursor'
export interface DropcursorOptions {
/**
* The color of the drop cursor. Use `false` to apply no color and rely only on class.
* @default 'currentColor'
* @example 'red'
*/
color?: string | false
/**
* The width of the drop cursor
* @default 1
* @example 2
*/
width: number | undefined
/**
* The class of the drop cursor
* @default undefined
* @example 'drop-cursor'
*/
class: string | undefined
}
/**
* This extension allows you to add a drop cursor to your editor.
* A drop cursor is a line that appears when you drag and drop content
* in-between nodes.
* @see https://tiptap.dev/api/extensions/dropcursor
*/
export const Dropcursor = Extension.create<DropcursorOptions>({
name: 'dropCursor',
addOptions() {
return {
color: 'currentColor',
width: 1,
class: undefined,
}
},
addProseMirrorPlugins() {
return [dropCursor(this.options)]
},
})
+1
View File
@@ -0,0 +1 @@
export * from './drop-cursor.js'
+110
View File
@@ -0,0 +1,110 @@
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
export interface FocusOptions {
/**
* The class name that should be added to the focused node.
* @default 'has-focus'
* @example 'is-focused'
*/
className: string
/**
* The mode by which the focused node is determined.
* - All: All nodes are marked as focused.
* - Deepest: Only the deepest node is marked as focused.
* - Shallowest: Only the shallowest node is marked as focused.
*
* @default 'all'
* @example 'deepest'
* @example 'shallowest'
*/
mode: 'all' | 'deepest' | 'shallowest'
}
/**
* This extension allows you to add a class to the focused node.
* @see https://www.tiptap.dev/api/extensions/focus
*/
export const Focus = Extension.create<FocusOptions>({
name: 'focus',
addOptions() {
return {
className: 'has-focus',
mode: 'all',
}
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('focus'),
props: {
decorations: ({ doc, selection }) => {
const { isEditable, isFocused } = this.editor
const { anchor } = selection
const decorations: Decoration[] = []
if (!isEditable || !isFocused) {
return DecorationSet.create(doc, [])
}
// Maximum Levels
let maxLevels = 0
if (this.options.mode === 'deepest') {
doc.descendants((node, pos) => {
if (node.isText) {
return
}
const isCurrent = anchor >= pos && anchor <= pos + node.nodeSize - 1
if (!isCurrent) {
return false
}
maxLevels += 1
})
}
// Loop through current
let currentLevel = 0
doc.descendants((node, pos) => {
if (node.isText) {
return false
}
const isCurrent = anchor >= pos && anchor <= pos + node.nodeSize - 1
if (!isCurrent) {
return false
}
currentLevel += 1
const outOfScope =
(this.options.mode === 'deepest' && maxLevels - currentLevel > 0) ||
(this.options.mode === 'shallowest' && currentLevel > 1)
if (outOfScope) {
return this.options.mode === 'deepest'
}
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
class: this.options.className,
}),
)
})
return DecorationSet.create(doc, decorations)
},
},
}),
]
},
})
+1
View File
@@ -0,0 +1 @@
export * from './focus.js'
+47
View File
@@ -0,0 +1,47 @@
import type { ParentConfig } from '@tiptap/core'
import { callOrReturn, Extension, getExtensionField } from '@tiptap/core'
import { gapCursor } from '@tiptap/pm/gapcursor'
declare module '@tiptap/core' {
interface NodeConfig<Options, Storage> {
/**
* A function to determine whether the gap cursor is allowed at the current position. Must return `true` or `false`.
* @default null
*/
allowGapCursor?:
| boolean
| null
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options>>['allowGapCursor']
}) => boolean | null)
}
}
/**
* This extension allows you to add a gap cursor to your editor.
* A gap cursor is a cursor that appears when you click on a place
* where no content is present, for example inbetween nodes.
* @see https://tiptap.dev/api/extensions/gapcursor
*/
export const Gapcursor = Extension.create({
name: 'gapCursor',
addProseMirrorPlugins() {
return [gapCursor()]
},
extendNodeSchema(extension) {
const context = {
name: extension.name,
options: extension.options,
storage: extension.storage,
}
return {
allowGapCursor: callOrReturn(getExtensionField(extension, 'allowGapCursor', context)) ?? null,
}
},
})
+1
View File
@@ -0,0 +1 @@
export * from './gap-cursor.js'
+8
View File
@@ -0,0 +1,8 @@
export * from './character-count/index.js'
export * from './drop-cursor/index.js'
export * from './focus/index.js'
export * from './gap-cursor/index.js'
export * from './placeholder/index.js'
export * from './selection/index.js'
export * from './trailing-node/index.js'
export * from './undo-redo/index.js'
+1
View File
@@ -0,0 +1 @@
export * from './placeholder.js'
+172
View File
@@ -0,0 +1,172 @@
import type { Editor } from '@tiptap/core'
import { Extension, isNodeEmpty } from '@tiptap/core'
import type { Node as ProsemirrorNode } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
/**
* The default data attribute label
*/
const DEFAULT_DATA_ATTRIBUTE = 'placeholder'
/**
* Prepares the placeholder attribute by ensuring it is properly formatted.
* @param attr - The placeholder attribute string.
* @returns The prepared placeholder attribute string.
*/
export function preparePlaceholderAttribute(attr: string): string {
return (
attr
// replace whitespace with dashes
.replace(/\s+/g, '-')
// replace non-alphanumeric characters
// or special chars like $, %, &, etc.
// but not dashes
.replace(/[^a-zA-Z0-9-]/g, '')
// and replace any numeric character at the start
.replace(/^[0-9-]+/, '')
// and finally replace any stray, leading dashes
.replace(/^-+/, '')
.toLowerCase()
)
}
export interface PlaceholderOptions {
/**
* **The class name for the empty editor**
* @default 'is-editor-empty'
*/
emptyEditorClass: string
/**
* **The class name for empty nodes**
* @default 'is-empty'
*/
emptyNodeClass: string
/**
* **The data-attribute used for the placeholder label**
* Will be prepended with `data-` and converted to kebab-case and cleaned of special characters.
* @default 'placeholder'
*/
dataAttribute: string
/**
* **The placeholder content**
*
* You can use a function to return a dynamic placeholder or a string.
* @default 'Write something …'
*/
placeholder:
| ((PlaceholderProps: { editor: Editor; node: ProsemirrorNode; pos: number; hasAnchor: boolean }) => string)
| string
/**
* **Checks if the placeholder should be only shown when the editor is editable.**
*
* If true, the placeholder will only be shown when the editor is editable.
* If false, the placeholder will always be shown.
* @default true
*/
showOnlyWhenEditable: boolean
/**
* **Checks if the placeholder should be only shown when the current node is empty.**
*
* If true, the placeholder will only be shown when the current node is empty.
* If false, the placeholder will be shown when any node is empty.
* @default true
*/
showOnlyCurrent: boolean
/**
* **Controls if the placeholder should be shown for all descendents.**
*
* If true, the placeholder will be shown for all descendents.
* If false, the placeholder will only be shown for the current node.
* @default false
*/
includeChildren: boolean
}
/**
* This extension allows you to add a placeholder to your editor.
* A placeholder is a text that appears when the editor or a node is empty.
* @see https://www.tiptap.dev/api/extensions/placeholder
*/
export const Placeholder = Extension.create<PlaceholderOptions>({
name: 'placeholder',
addOptions() {
return {
emptyEditorClass: 'is-editor-empty',
emptyNodeClass: 'is-empty',
dataAttribute: DEFAULT_DATA_ATTRIBUTE,
placeholder: 'Write something …',
showOnlyWhenEditable: true,
showOnlyCurrent: true,
includeChildren: false,
}
},
addProseMirrorPlugins() {
const dataAttribute = this.options.dataAttribute
? `data-${preparePlaceholderAttribute(this.options.dataAttribute)}`
: `data-${DEFAULT_DATA_ATTRIBUTE}`
return [
new Plugin({
key: new PluginKey('placeholder'),
props: {
decorations: ({ doc, selection }) => {
const active = this.editor.isEditable || !this.options.showOnlyWhenEditable
const { anchor } = selection
const decorations: Decoration[] = []
if (!active) {
return null
}
const isEmptyDoc = this.editor.isEmpty
doc.descendants((node, pos) => {
const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize
const isEmpty = !node.isLeaf && isNodeEmpty(node)
if (!node.type.isTextblock) {
return this.options.includeChildren
}
if ((hasAnchor || !this.options.showOnlyCurrent) && isEmpty) {
const classes = [this.options.emptyNodeClass]
if (isEmptyDoc) {
classes.push(this.options.emptyEditorClass)
}
const decoration = Decoration.node(pos, pos + node.nodeSize, {
class: classes.join(' '),
[dataAttribute]:
typeof this.options.placeholder === 'function'
? this.options.placeholder({
editor: this.editor,
node,
pos,
hasAnchor,
})
: this.options.placeholder,
})
decorations.push(decoration)
}
return this.options.includeChildren
})
return DecorationSet.create(doc, decorations)
},
},
}),
]
},
})
+1
View File
@@ -0,0 +1 @@
export * from './selection.js'
+57
View File
@@ -0,0 +1,57 @@
import { Extension, isNodeSelection } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
export type SelectionOptions = {
/**
* The class name that should be added to the selected text.
* @default 'selection'
* @example 'is-selected'
*/
className: string
}
/**
* This extension allows you to add a class to the selected text.
* @see https://www.tiptap.dev/api/extensions/selection
*/
export const Selection = Extension.create<SelectionOptions>({
name: 'selection',
addOptions() {
return {
className: 'selection',
}
},
addProseMirrorPlugins() {
const { editor, options } = this
return [
new Plugin({
key: new PluginKey('selection'),
props: {
decorations(state) {
if (
state.selection.empty ||
editor.isFocused ||
!editor.isEditable ||
isNodeSelection(state.selection) ||
editor.view.dragging
) {
return null
}
return DecorationSet.create(state.doc, [
Decoration.inline(state.selection.from, state.selection.to, {
class: options.className,
}),
])
},
},
}),
]
},
})
export default Selection
+1
View File
@@ -0,0 +1 @@
export * from './trailing-node.js'
+99
View File
@@ -0,0 +1,99 @@
import { Extension } from '@tiptap/core'
import type { Node, NodeType } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
export const skipTrailingNodeMeta = 'skipTrailingNode'
function nodeEqualsType({ types, node }: { types: NodeType | NodeType[]; node: Node | null | undefined }) {
return (node && Array.isArray(types) && types.includes(node.type)) || node?.type === types
}
/**
* Extension based on:
* - https://github.com/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/TrailingNode.js
* - https://github.com/remirror/remirror/blob/e0f1bec4a1e8073ce8f5500d62193e52321155b9/packages/prosemirror-trailing-node/src/trailing-node-plugin.ts
*/
export interface TrailingNodeOptions {
/**
* The node type that should be inserted at the end of the document.
* @note the node will always be added to the `notAfter` lists to
* prevent an infinite loop.
* @default undefined
*/
node?: string
/**
* The node types after which the trailing node should not be inserted.
* @default ['paragraph']
*/
notAfter?: string | string[]
}
/**
* This extension allows you to add an extra node at the end of the document.
* @see https://www.tiptap.dev/api/extensions/trailing-node
*/
export const TrailingNode = Extension.create<TrailingNodeOptions>({
name: 'trailingNode',
addOptions() {
return {
node: undefined,
notAfter: [],
}
},
addProseMirrorPlugins() {
const plugin = new PluginKey(this.name)
const defaultNode =
this.options.node || this.editor.schema.topNodeType.contentMatch.defaultType?.name || 'paragraph'
const disabledNodes = Object.entries(this.editor.schema.nodes)
.map(([, value]) => value)
.filter(node => (this.options.notAfter || []).concat(defaultNode).includes(node.name))
return [
new Plugin({
key: plugin,
appendTransaction: (transactions, __, state) => {
const { doc, tr, schema } = state
const shouldInsertNodeAtEnd = plugin.getState(state)
const endPosition = doc.content.size
const type = schema.nodes[defaultNode]
if (transactions.some(transaction => transaction.getMeta(skipTrailingNodeMeta))) {
return
}
if (!shouldInsertNodeAtEnd) {
return
}
return tr.insert(endPosition, type.create())
},
state: {
init: (_, state) => {
const lastNode = state.tr.doc.lastChild
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
},
apply: (tr, value) => {
if (!tr.docChanged) {
return value
}
// Ignore transactions from UniqueID extension to prevent infinite loops
// when UniqueID adds IDs to newly inserted trailing nodes
if (tr.getMeta('__uniqueIDTransaction')) {
return value
}
const lastNode = tr.doc.lastChild
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
},
},
}),
]
},
})
+1
View File
@@ -0,0 +1 @@
export * from './undo-redo.js'
+86
View File
@@ -0,0 +1,86 @@
import { Extension } from '@tiptap/core'
import { history, redo, undo } from '@tiptap/pm/history'
export interface UndoRedoOptions {
/**
* The amount of history events that are collected before the oldest events are discarded.
* @default 100
* @example 50
*/
depth: number
/**
* The delay (in milliseconds) between changes after which a new group should be started.
* @default 500
* @example 1000
*/
newGroupDelay: number
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
undoRedo: {
/**
* Undo recent changes
* @example editor.commands.undo()
*/
undo: () => ReturnType
/**
* Reapply reverted changes
* @example editor.commands.redo()
*/
redo: () => ReturnType
}
}
}
/**
* This extension allows you to undo and redo recent changes.
* @see https://www.tiptap.dev/api/extensions/undo-redo
*
* **Important**: If the `@tiptap/extension-collaboration` package is used, make sure to remove
* the `undo-redo` extension, as it is not compatible with the `collaboration` extension.
*
* `@tiptap/extension-collaboration` uses its own history implementation.
*/
export const UndoRedo = Extension.create<UndoRedoOptions>({
name: 'undoRedo',
addOptions() {
return {
depth: 100,
newGroupDelay: 500,
}
},
addCommands() {
return {
undo:
() =>
({ state, dispatch }) => {
return undo(state, dispatch)
},
redo:
() =>
({ state, dispatch }) => {
return redo(state, dispatch)
},
}
},
addProseMirrorPlugins() {
return [history(this.options)]
},
addKeyboardShortcuts() {
return {
'Mod-z': () => this.editor.commands.undo(),
'Shift-Mod-z': () => this.editor.commands.redo(),
'Mod-y': () => this.editor.commands.redo(),
// Russian keyboard layouts
'Mod-я': () => this.editor.commands.undo(),
'Shift-Mod-я': () => this.editor.commands.redo(),
}
},
})