snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
# @tiptap/extension-invisible-characters
[![Version](https://img.shields.io/npm/v/@tiptap/extension-invisible-characters.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-invisible-characters)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-invisible-characters.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-invisible-characters.svg)](https://www.npmjs.com/package/@tiptap/extension-invisible-characters)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
## Official Documentation
Documentation can be found on the [Tiptap website](https://tiptap.dev).
## License
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).

View File

@@ -0,0 +1,54 @@
{
"name": "@tiptap/extension-invisible-characters",
"version": "3.30.3",
"description": "invisible characters extension for tiptap",
"keywords": [
"tiptap",
"tiptap extension"
],
"homepage": "https://tiptap.dev/docs/editor/extensions/functionality/invisiblecharacters",
"bugs": {
"url": "https://github.com/ueberdosis/tiptap/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/extension-invisible-characters"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"files": [
"src",
"dist"
],
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": {
"import": "./dist/index.d.ts",
"require": "./dist/index.d.cts"
},
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "vp pack"
},
"devDependencies": {
"@tiptap/core": "workspace:^",
"@tiptap/extension-text-style": "workspace:^",
"@tiptap/pm": "workspace:^"
},
"peerDependencies": {
"@tiptap/core": "workspace:*",
"@tiptap/extension-text-style": "workspace:*",
"@tiptap/pm": "workspace:*"
}
}

View File

@@ -0,0 +1,11 @@
import { InvisibleCharacters } from './invisible-characters.js'
export * from './invisible-characters.js'
export * from './plugin/invisible-characters/hardBreak.js'
export * from './plugin/invisible-characters/paragraph.js'
export * from './plugin/invisible-characters/space.js'
export * from './plugin/InvisibleCharacter.js'
export * from './plugin/InvisibleNode.js'
export * from './types.js'
export default InvisibleCharacters

View File

@@ -0,0 +1,95 @@
import { Extension } from '@tiptap/core'
import { InvisibleCharactersPlugin, InvisibleCharactersPluginKey } from './plugin/index.js'
import { HardBreakNode } from './plugin/invisible-characters/hardBreak.js'
import { ParagraphNode } from './plugin/invisible-characters/paragraph.js'
import { SpaceCharacter } from './plugin/invisible-characters/space.js'
import type { InvisibleCharactersOptions } from './types.js'
export { HardBreakNode, ParagraphNode, SpaceCharacter }
declare module '@tiptap/core' {
interface Commands<ReturnType> {
invisibleCharacters: {
/**
* Show invisible characters
*/
showInvisibleCharacters: (show?: boolean) => ReturnType
/**
* Hide invisible characters
*/
hideInvisibleCharacters: () => ReturnType
/**
* Toggle invisible characters
*/
toggleInvisibleCharacters: () => ReturnType
}
}
}
export const InvisibleCharacters = Extension.create<InvisibleCharactersOptions>({
name: 'invisibleCharacters',
addOptions() {
return {
visible: true,
builders: [new SpaceCharacter(), new ParagraphNode(), new HardBreakNode()],
injectCSS: true,
injectNonce: undefined,
}
},
addProseMirrorPlugins() {
return [InvisibleCharactersPlugin(this.editor.state, this.options)]
},
addStorage() {
return {
visibility: () => this.options.visible,
}
},
onBeforeCreate() {
this.storage.visibility = () => {
return InvisibleCharactersPluginKey.getState(this.editor.state)?.visible
}
},
addCommands() {
return {
showInvisibleCharacters:
(visibility = true) =>
({ dispatch, tr }) => {
if (dispatch) {
tr.setMeta('setInvisibleCharactersVisible', visibility)
}
return true
},
hideInvisibleCharacters:
() =>
({ dispatch, tr }) => {
if (dispatch) {
tr.setMeta('setInvisibleCharactersVisible', false)
}
return true
},
toggleInvisibleCharacters:
() =>
({ dispatch, tr, state }) => {
const visibility = !InvisibleCharactersPluginKey.getState(state)?.visible
if (dispatch) {
tr.setMeta('setInvisibleCharactersVisible', visibility)
}
return true
},
}
},
})
export default InvisibleCharacters

View File

@@ -0,0 +1,49 @@
import type { Node as PMNode } from '@tiptap/pm/model'
import type { DecorationSet } from '@tiptap/pm/view'
import { createDecorationWidget } from './utils/create-decoration-widget.js'
import { textBetween } from './utils/text-between.js'
export interface InvisibleCharacterOptions {
type: string
predicate: (value: string) => boolean
priority?: number
content?: string
}
export class InvisibleCharacter {
predicate: (value: string) => boolean
content?: string
type: string
priority: number
constructor(options: InvisibleCharacterOptions) {
this.predicate = options.predicate
this.type = options.type
this.content = options.content
this.priority = options.priority || 100
}
createDecoration(from: number, to: number, doc: PMNode, decorations: DecorationSet) {
const textContent = textBetween(from, to, doc)
return textContent.reduce((oldDecorations, currentPosition) => {
return currentPosition.text.split('').reduce((innerDecorations, char, i) => {
return this.test(char)
? innerDecorations.add(doc, [
createDecorationWidget(currentPosition.pos + i, this.type, this.content),
])
: innerDecorations
}, oldDecorations)
}, decorations)
}
test(value: string) {
return this.predicate(value)
}
}
export default InvisibleCharacter

View File

@@ -0,0 +1,93 @@
import type { EditorState } from '@tiptap/pm/state'
import { AllSelection, Plugin, PluginKey } from '@tiptap/pm/state'
import { DecorationSet } from '@tiptap/pm/view'
import type { InvisibleCharactersOptions, PluginState } from '../types.js'
import { stateReducer } from './reducers.js'
import { style } from './style.js'
import { createStyleTag } from './utils/create-style-tag.js'
import { getUpdatedRanges } from './utils/get-updated-ranges.js'
export const InvisibleCharactersPluginKey = new PluginKey<PluginState>('invisibleCharacters')
export const InvisibleCharactersPlugin = (
state: EditorState,
options: InvisibleCharactersOptions,
) => {
const key = InvisibleCharactersPluginKey
const emptyDecorationSet = DecorationSet.create(state.doc, [])
const addDecorationsBetweenPositions = (
from: number,
to: number,
editorState: EditorState,
decorations: DecorationSet,
) => {
return options.builders
.sort((builderA, builderB) => {
if (builderA.priority > builderB.priority) {
return 1
}
return -1
})
.reduce((newDecos, builder) => {
return builder.createDecoration(from, to, editorState.doc, newDecos)
}, decorations)
}
return new Plugin({
key,
state: {
init: () => {
const { $from, $to } = new AllSelection(state.doc)
if (options.injectCSS && document) {
createStyleTag(style, options.injectNonce)
}
return {
visible: options.visible,
decorations: addDecorationsBetweenPositions(
$from.pos,
$to.pos,
state,
DecorationSet.empty,
),
}
},
apply: (tr, pluginState, _, currentState) => {
const newPluginState = stateReducer(
pluginState,
tr.getMeta('setInvisibleCharactersVisible'),
)
const decorations = getUpdatedRanges(tr).reduce(
(nextDecorations, [from, to]) => {
return addDecorationsBetweenPositions(from, to, currentState, nextDecorations)
},
newPluginState.decorations.map(tr.mapping, tr.doc),
)
return {
...newPluginState,
decorations,
}
},
},
props: {
decorations(editorState) {
const pluginState = this.getState(editorState)
const visible = pluginState?.visible
const decorations = pluginState?.decorations
return visible ? decorations : emptyDecorationSet
},
},
})
}
export default InvisibleCharactersPlugin

View File

@@ -0,0 +1,61 @@
import type { Node as PMNode } from '@tiptap/pm/model'
import type { DecorationSet } from '@tiptap/pm/view'
import { createDecorationWidget } from './utils/create-decoration-widget.js'
export interface InvisibleNodeOptions {
content?: string
position?: (node: PMNode, pos: number) => number
predicate: (value: PMNode) => boolean
priority?: number
type: string
}
/** Default position for nodes is always at the end of the node */
const defaultPosition = (node: PMNode, pos: number) => pos + node.nodeSize - 1
export class InvisibleNode {
predicate: (value: PMNode) => boolean
position: (node: PMNode, pos: number) => number
content?: string
type: string
priority: number
constructor(options: InvisibleNodeOptions) {
this.predicate = options.predicate
this.type = options.type
this.position = options.position || defaultPosition
this.content = options.content
this.priority = options.priority || 100
}
createDecoration(from: number, to: number, doc: PMNode, decorations: DecorationSet) {
let newDecorations = decorations
doc.nodesBetween(from, to, (node, pos) => {
if (this.test(node)) {
const decorationPosition = this.position(node, pos)
const oldDecorations = newDecorations.find(
decorationPosition,
decorationPosition,
spec => spec.key === this.type,
)
newDecorations = newDecorations
.remove(oldDecorations)
.add(doc, [createDecorationWidget(decorationPosition, this.type, this.content)])
}
})
return newDecorations
}
test(value: PMNode) {
return this.predicate(value)
}
}
export default InvisibleNode

View File

@@ -0,0 +1 @@
export * from './InvisibleCharactersPlugin.js'

View File

@@ -0,0 +1,12 @@
import { InvisibleNode } from '../InvisibleNode.js'
export class HardBreakNode extends InvisibleNode {
constructor() {
super({
type: 'break',
predicate: node => node.type === node.type.schema.nodes.hardBreak,
})
}
}
export default HardBreakNode

View File

@@ -0,0 +1,12 @@
import { InvisibleNode } from '../InvisibleNode.js'
export class ParagraphNode extends InvisibleNode {
constructor() {
super({
type: 'paragraph',
predicate: node => node.type === node.type.schema.nodes.paragraph,
})
}
}
export default ParagraphNode

View File

@@ -0,0 +1,12 @@
import { InvisibleCharacter } from '../InvisibleCharacter.js'
export class SpaceCharacter extends InvisibleCharacter {
constructor() {
super({
type: 'space',
predicate: (char: string) => char === ' ',
})
}
}
export default SpaceCharacter

View File

@@ -0,0 +1,11 @@
import type { PluginState } from '../types.js'
export const stateReducer = (state: PluginState, visible: boolean): PluginState => {
if (visible === undefined) {
return state
}
return { ...state, visible }
}
export default stateReducer

View File

@@ -0,0 +1,43 @@
export const style = `.tiptap-invisible-character {
height: 0;
padding: 0;
pointer-events: none;
user-select: none;
width: 0;
}
.tiptap-invisible-character::before {
caret-color: inherit;
color: #aaa;
display: inline-block;
font-style: normal;
font-weight: 400;
line-height: 1em;
width: 0;
}
.tiptap-invisible-character--space::before {
content: '·'
}
.tiptap-invisible-character--break::before {
content: '¬'
}
.tiptap-invisible-character--paragraph::before {
content: '¶'
}
.tiptap-invisible-character + img.ProseMirror-separator {
height: 0 !important;
pointer-events: none;
user-select: none;
width: 0 !important;
}
.is-empty[data-placeholder].has-focus > .tiptap-invisible-character {
display: none;
}
`
export default style

View File

@@ -0,0 +1,24 @@
import { Decoration } from '@tiptap/pm/view'
export const createDecorationWidget = (pos: number, type: string, content?: string) => {
const createElement = () => {
const newElement = document.createElement('span')
newElement.classList.add('tiptap-invisible-character')
newElement.classList.add(`tiptap-invisible-character--${type}`)
if (content) {
newElement.textContent = content
}
return newElement
}
return Decoration.widget(pos, createElement, {
key: type,
marks: [],
side: 1000,
})
}
export default createDecorationWidget

View File

@@ -0,0 +1,23 @@
export const createStyleTag = (style: string, nonce?: string): HTMLStyleElement => {
const tiptapStyleTag = <HTMLStyleElement>(
document.querySelector('style[data-tiptap-extension-invisible-characters-style]')
)
if (tiptapStyleTag !== null) {
return tiptapStyleTag
}
const styleNode = document.createElement('style')
if (nonce) {
styleNode.setAttribute('nonce', nonce)
}
styleNode.setAttribute('data-tiptap-extension-invisible-characters-style', '')
styleNode.innerHTML = style
document.getElementsByTagName('head')[0].appendChild(styleNode)
return styleNode
}
export default createStyleTag

View File

@@ -0,0 +1,17 @@
import type { Transaction } from '@tiptap/pm/state'
type StepRange = [from: number, to: number]
export const getUpdatedRanges = ({ mapping }: Transaction): StepRange[] => {
const ranges: StepRange[] = []
mapping.maps.forEach((stepMap, i) => {
stepMap.forEach((_oldStart, _oldEnd, newStart, newEnd) => {
ranges.push([mapping.slice(i + 1).map(newStart), mapping.slice(i + 1).map(newEnd)])
})
})
return ranges
}
export default getUpdatedRanges

View File

@@ -0,0 +1,22 @@
import type { Node as PMNode } from '@tiptap/pm/model'
import type { Position } from '../../types.js'
export const textBetween = (from: number, to: number, doc: PMNode) => {
const positions: Position[] = []
doc.nodesBetween(from, to, (node, pos) => {
if (node.isText) {
const offset = Math.max(from, pos) - pos
positions.push({
pos: pos + offset,
text: node.text?.slice(offset, to - pos) || '',
})
}
})
return positions
}
export default textBetween

View File

@@ -0,0 +1,34 @@
import type { DecorationSet } from '@tiptap/pm/view'
import type { InvisibleCharacter } from './plugin/InvisibleCharacter.js'
import type { InvisibleNode } from './plugin/InvisibleNode.js'
export interface InvisibleCharactersOptions {
visible: boolean
builders: Array<InvisibleCharacter | InvisibleNode>
injectCSS: boolean
injectNonce: string | undefined
}
export interface Position {
pos: number
text: string
}
export interface PluginState {
visible: boolean
decorations: DecorationSet
}
export interface InvisibleCharactersStorage {
/**
* Get whether the invisible characters are shown or not
*/
visibility: () => boolean
}
declare module '@tiptap/core' {
interface Storage {
invisibleCharacters: InvisibleCharactersStorage
}
}

View File

@@ -0,0 +1,33 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import HardBreak from '@tiptap/extension-hard-break'
import Heading from '@tiptap/extension-heading'
import InvisibleCharacters from '@tiptap/extension-invisible-characters'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
describe('InvisibleCharacters', () => {
let editor: Editor
beforeEach(() => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document, Paragraph, Heading, Text, InvisibleCharacters, HardBreak],
content:
'<h1>This is a heading.</h1><p>This<br>is<br>a<br>paragraph.</p><p>Another paragraph.</p>',
})
})
afterEach(() => {
editor.destroy()
})
it('renders invisible character decorations when shown', () => {
editor.commands.showInvisibleCharacters()
const decorations = editor.view.dom.querySelectorAll('[class*="tiptap-invisible-character"]')
expect(decorations.length).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite-plus'
import { basePackConfig } from '../../pack.config.mjs'
export default defineConfig({
pack: {
entry: ['src/index.ts'],
...basePackConfig(),
},
})