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-text-style
[![Version](https://img.shields.io/npm/v/@tiptap/extension-text-style.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-text-style)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-text-style.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-text-style.svg)](https://www.npmjs.com/package/@tiptap/extension-text-style)
[![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,106 @@
{
"name": "@tiptap/extension-text-style",
"version": "3.30.3",
"description": "text style extension for tiptap",
"keywords": [
"tiptap",
"tiptap extension"
],
"homepage": "https://tiptap.dev",
"bugs": {
"url": "https://github.com/ueberdosis/tiptap/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/extension-text-style"
},
"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"
},
"./background-color": {
"types": {
"import": "./dist/background-color/index.d.ts",
"require": "./dist/background-color/index.d.cts"
},
"import": "./dist/background-color/index.js",
"require": "./dist/background-color/index.cjs"
},
"./color": {
"types": {
"import": "./dist/color/index.d.ts",
"require": "./dist/color/index.d.cts"
},
"import": "./dist/color/index.js",
"require": "./dist/color/index.cjs"
},
"./font-family": {
"types": {
"import": "./dist/font-family/index.d.ts",
"require": "./dist/font-family/index.d.cts"
},
"import": "./dist/font-family/index.js",
"require": "./dist/font-family/index.cjs"
},
"./font-size": {
"types": {
"import": "./dist/font-size/index.d.ts",
"require": "./dist/font-size/index.d.cts"
},
"import": "./dist/font-size/index.js",
"require": "./dist/font-size/index.cjs"
},
"./line-height": {
"types": {
"import": "./dist/line-height/index.d.ts",
"require": "./dist/line-height/index.d.cts"
},
"import": "./dist/line-height/index.js",
"require": "./dist/line-height/index.cjs"
},
"./text-style": {
"types": {
"import": "./dist/text-style/index.d.ts",
"require": "./dist/text-style/index.d.cts"
},
"import": "./dist/text-style/index.js",
"require": "./dist/text-style/index.cjs"
},
"./text-style-kit": {
"types": {
"import": "./dist/text-style-kit/index.d.ts",
"require": "./dist/text-style-kit/index.d.cts"
},
"import": "./dist/text-style-kit/index.js",
"require": "./dist/text-style-kit/index.cjs"
}
},
"scripts": {
"build": "vp pack"
},
"devDependencies": {
"@tiptap/core": "workspace:^"
},
"peerDependencies": {
"@tiptap/core": "workspace:*"
}
}

View File

@@ -0,0 +1,35 @@
import { BackgroundColor } from './background-color.js'
import { describe, expect, it } from 'vite-plus/test'
const ext: any = (BackgroundColor as any).configure()
const globalAttrs = ext.config.addGlobalAttributes && ext.config.addGlobalAttributes.call(ext)[0]
const { parseHTML } = globalAttrs.attributes.backgroundColor
describe('background-color parseHTML', () => {
it('parses rgb(...) inline style', () => {
const el = document.createElement('span')
el.setAttribute('style', 'background-color: rgb(0, 255, 0)')
const parsed = parseHTML(el)
expect(parsed).toBe('rgb(0, 255, 0)')
})
it('parses hex inline style', () => {
const el = document.createElement('span')
el.setAttribute('style', 'background-color: #00ff00')
const parsed = parseHTML(el)
expect(parsed).toBe('#00ff00')
})
it('parses hsla inline style', () => {
const el = document.createElement('span')
el.setAttribute('style', 'background-color: hsla(120, 100%, 50%, 0.3)')
const parsed = parseHTML(el)
expect(parsed).toBe('hsla(120, 100%, 50%, 0.3)')
})
})

View File

@@ -0,0 +1,100 @@
import '../text-style/index.js'
import { Extension, getStyleProperty } from '@tiptap/core'
export type BackgroundColorOptions = {
/**
* The types where the color can be applied
* @default ['textStyle']
* @example ['heading', 'paragraph']
*/
types: string[]
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
backgroundColor: {
/**
* Set the text color
* @param backgroundColor The color to set
* @example editor.commands.setColor('red')
*/
setBackgroundColor: (backgroundColor: string) => ReturnType
/**
* Unset the text backgroundColor
* @example editor.commands.unsetBackgroundColor()
*/
unsetBackgroundColor: () => ReturnType
}
}
}
// @ts-ignore because the module is not found during dts build
declare module '@tiptap/extension-text-style' {
interface TextStyleAttributes {
backgroundColor?: string | null
}
}
/**
* This extension allows you to color your text.
* @see https://tiptap.dev/api/extensions/background-color
*/
export const BackgroundColor = Extension.create<BackgroundColorOptions>({
name: 'backgroundColor',
addOptions() {
return {
types: ['textStyle'],
}
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
backgroundColor: {
default: null,
parseHTML: element => {
// Prefer the raw inline `style` attribute so we preserve the
// original format (e.g. `#rrggbb`) instead of the canonicalized
// `rgb(...)` value returned by `element.style.backgroundColor`.
const value =
getStyleProperty(element, 'background-color') ?? element.style.backgroundColor
return value?.replace(/['"]+/g, '')
},
renderHTML: attributes => {
if (!attributes.backgroundColor) {
return {}
}
return {
style: `background-color: ${attributes.backgroundColor}`,
}
},
},
},
},
]
},
addCommands() {
return {
setBackgroundColor:
backgroundColor =>
({ chain }) => {
return chain().setMark('textStyle', { backgroundColor }).run()
},
unsetBackgroundColor:
() =>
({ chain }) => {
return chain()
.setMark('textStyle', { backgroundColor: null })
.removeEmptyTextStyle()
.run()
},
}
},
})

View File

@@ -0,0 +1,5 @@
import { BackgroundColor } from './background-color.js'
export * from './background-color.js'
export default BackgroundColor

View File

@@ -0,0 +1,35 @@
import { Color } from './color.js'
import { describe, expect, it } from 'vite-plus/test'
const ext: any = (Color as any).configure()
const globalAttrs = ext.config.addGlobalAttributes && ext.config.addGlobalAttributes.call(ext)[0]
const { parseHTML } = globalAttrs.attributes.color
describe('color parseHTML', () => {
it('parses rgb(...) inline style', () => {
const el = document.createElement('span')
el.setAttribute('style', 'color: rgb(149, 141, 241)')
const parsed = parseHTML(el)
expect(parsed).toBe('rgb(149, 141, 241)')
})
it('parses hex inline style', () => {
const el = document.createElement('span')
el.setAttribute('style', 'color: #958DF1')
const parsed = parseHTML(el)
expect(parsed).toBe('#958DF1')
})
it('parses hsla inline style', () => {
const el = document.createElement('span')
el.setAttribute('style', 'color: hsla(252, 100%, 80%, 0.5)')
const parsed = parseHTML(el)
expect(parsed).toBe('hsla(252, 100%, 80%, 0.5)')
})
})

View File

@@ -0,0 +1,96 @@
import '../text-style/index.js'
import { Extension, getStyleProperty } from '@tiptap/core'
export type ColorOptions = {
/**
* The types where the color can be applied
* @default ['textStyle']
* @example ['heading', 'paragraph']
*/
types: string[]
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
color: {
/**
* Set the text color
* @param color The color to set
* @example editor.commands.setColor('red')
*/
setColor: (color: string) => ReturnType
/**
* Unset the text color
* @example editor.commands.unsetColor()
*/
unsetColor: () => ReturnType
}
}
}
// @ts-ignore because the module is not found during dts build
declare module '@tiptap/extension-text-style' {
interface TextStyleAttributes {
color?: string | null
}
}
/**
* This extension allows you to color your text.
* @see https://tiptap.dev/api/extensions/color
*/
export const Color = Extension.create<ColorOptions>({
name: 'color',
addOptions() {
return {
types: ['textStyle'],
}
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
color: {
default: null,
parseHTML: element => {
// Prefer the raw inline `style` attribute so we preserve the
// original format (e.g. `#rrggbb`) instead of the canonicalized
// `rgb(...)` value returned by `element.style.color`.
const value = getStyleProperty(element, 'color') ?? element.style.color
return value?.replace(/['"]+/g, '')
},
renderHTML: attributes => {
if (!attributes.color) {
return {}
}
return {
style: `color: ${attributes.color}`,
}
},
},
},
},
]
},
addCommands() {
return {
setColor:
color =>
({ chain }) => {
return chain().setMark('textStyle', { color }).run()
},
unsetColor:
() =>
({ chain }) => {
return chain().setMark('textStyle', { color: null }).removeEmptyTextStyle().run()
},
}
},
})

View File

@@ -0,0 +1,5 @@
import { Color } from './color.js'
export * from './color.js'
export default Color

View File

@@ -0,0 +1,89 @@
import { FontFamily } from './font-family.js'
import { describe, expect, it } from 'vite-plus/test'
const ext: any = (FontFamily as any).configure()
const globalAttrs = ext.config.addGlobalAttributes && ext.config.addGlobalAttributes.call(ext)[0]
const { parseHTML } = globalAttrs.attributes.fontFamily
describe('fontFamily parseHTML', () => {
it('preserves unquoted multi-word values without canonicalizing to quoted form', () => {
const el = document.createElement('span')
el.setAttribute('style', 'font-family: Comic Sans MS, Comic Sans')
const parsed = parseHTML(el)
expect(parsed).toBe('Comic Sans MS, Comic Sans')
})
it('preserves single-quoted values without converting to double quotes', () => {
const el = document.createElement('span')
el.setAttribute('style', "font-family: 'Exo 2'")
const parsed = parseHTML(el)
expect(parsed).toBe("'Exo 2'")
})
it('preserves double-quoted values as-is', () => {
const el = document.createElement('span')
el.setAttribute('style', 'font-family: "Open Sans"')
const parsed = parseHTML(el)
expect(parsed).toBe('"Open Sans"')
})
it('parses simple single-word values', () => {
const el = document.createElement('span')
el.setAttribute('style', 'font-family: Inter')
const parsed = parseHTML(el)
expect(parsed).toBe('Inter')
})
it('parses generic keyword values', () => {
const el = document.createElement('span')
el.setAttribute('style', 'font-family: serif')
const parsed = parseHTML(el)
expect(parsed).toBe('serif')
})
it('preserves CSS variable values', () => {
const el = document.createElement('span')
el.setAttribute('style', 'font-family: var(--title-font-family)')
const parsed = parseHTML(el)
expect(parsed).toBe('var(--title-font-family)')
})
it('returns the last declaration when font-family is declared multiple times', () => {
const el = document.createElement('span')
el.setAttribute('style', 'font-family: Inter; font-family: Arial')
const parsed = parseHTML(el)
expect(parsed).toBe('Arial')
})
it('ignores other style declarations and only returns font-family', () => {
const el = document.createElement('span')
el.setAttribute('style', 'color: red; font-family: Inter; font-size: 16px')
const parsed = parseHTML(el)
expect(parsed).toBe('Inter')
})
it('is case-insensitive on the property name', () => {
const el = document.createElement('span')
el.setAttribute('style', 'FONT-FAMILY: Inter')
const parsed = parseHTML(el)
expect(parsed).toBe('Inter')
})
})

View File

@@ -0,0 +1,94 @@
import '../text-style/index.js'
import { Extension, getStyleProperty } from '@tiptap/core'
export type FontFamilyOptions = {
/**
* A list of node names where the font family can be applied.
* @default ['textStyle']
* @example ['heading', 'paragraph']
*/
types: string[]
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
fontFamily: {
/**
* Set the font family
* @param fontFamily The font family
* @example editor.commands.setFontFamily('Arial')
*/
setFontFamily: (fontFamily: string) => ReturnType
/**
* Unset the font family
* @example editor.commands.unsetFontFamily()
*/
unsetFontFamily: () => ReturnType
}
}
}
// @ts-ignore because the module is not found during dts build
declare module '@tiptap/extension-text-style' {
interface TextStyleAttributes {
fontFamily?: string | null
}
}
/**
* This extension allows you to set a font family for text.
* @see https://www.tiptap.dev/api/extensions/font-family
*/
export const FontFamily = Extension.create<FontFamilyOptions>({
name: 'fontFamily',
addOptions() {
return {
types: ['textStyle'],
}
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
fontFamily: {
default: null,
// Prefer the raw inline `style` attribute so unquoted or
// single-quoted multi-word names are preserved instead of being
// canonicalized by `element.style.fontFamily`, which forces double
// quotes that then get HTML-encoded to `&quot;` on serialization.
parseHTML: element =>
getStyleProperty(element, 'font-family') ?? element.style.fontFamily,
renderHTML: attributes => {
if (!attributes.fontFamily) {
return {}
}
return {
style: `font-family: ${attributes.fontFamily}`,
}
},
},
},
},
]
},
addCommands() {
return {
setFontFamily:
fontFamily =>
({ chain }) => {
return chain().setMark('textStyle', { fontFamily }).run()
},
unsetFontFamily:
() =>
({ chain }) => {
return chain().setMark('textStyle', { fontFamily: null }).removeEmptyTextStyle().run()
},
}
},
})

View File

@@ -0,0 +1,5 @@
import { FontFamily } from './font-family.js'
export * from './font-family.js'
export default FontFamily

View File

@@ -0,0 +1,35 @@
import { FontSize } from './font-size.js'
import { describe, expect, it } from 'vite-plus/test'
const ext: any = (FontSize as any).configure()
const globalAttrs = ext.config.addGlobalAttributes && ext.config.addGlobalAttributes.call(ext)[0]
const { parseHTML } = globalAttrs.attributes.fontSize
describe('fontSize parseHTML', () => {
it('parses simple inline style', () => {
const el = document.createElement('span')
el.setAttribute('style', 'font-size: 16px')
const parsed = parseHTML(el)
expect(parsed).toBe('16px')
})
it('returns the last declaration when font-size is declared multiple times', () => {
const el = document.createElement('span')
el.setAttribute('style', 'font-size: 12px; font-size: 18px')
const parsed = parseHTML(el)
expect(parsed).toBe('18px')
})
it('ignores other style declarations and only returns font-size', () => {
const el = document.createElement('span')
el.setAttribute('style', 'color: red; font-size: 1.5em; line-height: 1.2')
const parsed = parseHTML(el)
expect(parsed).toBe('1.5em')
})
})

View File

@@ -0,0 +1,92 @@
import '../text-style/index.js'
import { Extension, getStyleProperty } from '@tiptap/core'
export type FontSizeOptions = {
/**
* A list of node names where the font size can be applied.
* @default ['textStyle']
* @example ['heading', 'paragraph']
*/
types: string[]
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
fontSize: {
/**
* Set the font size
* @param fontSize The font size
* @example editor.commands.setFontSize('16px')
*/
setFontSize: (fontSize: string) => ReturnType
/**
* Unset the font size
* @example editor.commands.unsetFontSize()
*/
unsetFontSize: () => ReturnType
}
}
}
// @ts-ignore because the module is not found during dts build
declare module '@tiptap/extension-text-style' {
interface TextStyleAttributes {
fontSize?: string | null
}
}
/**
* This extension allows you to set a font size for text.
* @see https://www.tiptap.dev/api/extensions/font-size
*/
export const FontSize = Extension.create<FontSizeOptions>({
name: 'fontSize',
addOptions() {
return {
types: ['textStyle'],
}
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
fontSize: {
default: null,
// Prefer the raw inline `style` attribute so the original format
// is preserved instead of the canonicalized value returned by
// `element.style.fontSize`.
parseHTML: element => getStyleProperty(element, 'font-size') ?? element.style.fontSize,
renderHTML: attributes => {
if (!attributes.fontSize) {
return {}
}
return {
style: `font-size: ${attributes.fontSize}`,
}
},
},
},
},
]
},
addCommands() {
return {
setFontSize:
fontSize =>
({ chain }) => {
return chain().setMark('textStyle', { fontSize }).run()
},
unsetFontSize:
() =>
({ chain }) => {
return chain().setMark('textStyle', { fontSize: null }).removeEmptyTextStyle().run()
},
}
},
})

View File

@@ -0,0 +1,5 @@
import { FontSize } from './font-size.js'
export * from './font-size.js'
export default FontSize

View File

@@ -0,0 +1,14 @@
/* oslint-disableno-empty-object-type */
export * from './background-color/index.js'
export * from './color/index.js'
export * from './font-family/index.js'
export * from './font-size/index.js'
export * from './line-height/index.js'
export * from './text-style/index.js'
export * from './text-style-kit/index.js'
/**
* The available text style attributes.
*/
export interface TextStyleAttributes extends Record<string, any> {}

View File

@@ -0,0 +1,5 @@
import { LineHeight } from './line-height.js'
export * from './line-height.js'
export default LineHeight

View File

@@ -0,0 +1,35 @@
import { LineHeight } from './line-height.js'
import { describe, expect, it } from 'vite-plus/test'
const ext: any = (LineHeight as any).configure()
const globalAttrs = ext.config.addGlobalAttributes && ext.config.addGlobalAttributes.call(ext)[0]
const { parseHTML } = globalAttrs.attributes.lineHeight
describe('lineHeight parseHTML', () => {
it('parses simple inline style', () => {
const el = document.createElement('span')
el.setAttribute('style', 'line-height: 1.5')
const parsed = parseHTML(el)
expect(parsed).toBe('1.5')
})
it('returns the last declaration when line-height is declared multiple times', () => {
const el = document.createElement('span')
el.setAttribute('style', 'line-height: 1.2; line-height: 2')
const parsed = parseHTML(el)
expect(parsed).toBe('2')
})
it('ignores other style declarations and only returns line-height', () => {
const el = document.createElement('span')
el.setAttribute('style', 'color: red; line-height: 24px; font-size: 16px')
const parsed = parseHTML(el)
expect(parsed).toBe('24px')
})
})

View File

@@ -0,0 +1,93 @@
import '../text-style/index.js'
import { Extension, getStyleProperty } from '@tiptap/core'
export type LineHeightOptions = {
/**
* A list of node names where the line height can be applied.
* @default ['textStyle']
* @example ['heading', 'paragraph']
*/
types: string[]
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
lineHeight: {
/**
* Set the line height
* @param lineHeight The line height
* @example editor.commands.setLineHeight('1.5')
*/
setLineHeight: (lineHeight: string) => ReturnType
/**
* Unset the line height
* @example editor.commands.unsetLineHeight()
*/
unsetLineHeight: () => ReturnType
}
}
}
// @ts-ignore because the module is not found during dts build
declare module '@tiptap/extension-text-style' {
interface TextStyleAttributes {
lineHeight?: string | null
}
}
/**
* This extension allows you to set the line-height for text.
* @see https://www.tiptap.dev/api/extensions/line-height
*/
export const LineHeight = Extension.create<LineHeightOptions>({
name: 'lineHeight',
addOptions() {
return {
types: ['textStyle'],
}
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
lineHeight: {
default: null,
// Prefer the raw inline `style` attribute so the original format
// is preserved instead of the canonicalized value returned by
// `element.style.lineHeight`.
parseHTML: element =>
getStyleProperty(element, 'line-height') ?? element.style.lineHeight,
renderHTML: attributes => {
if (!attributes.lineHeight) {
return {}
}
return {
style: `line-height: ${attributes.lineHeight}`,
}
},
},
},
},
]
},
addCommands() {
return {
setLineHeight:
lineHeight =>
({ chain }) => {
return chain().setMark('textStyle', { lineHeight }).run()
},
unsetLineHeight:
() =>
({ chain }) => {
return chain().setMark('textStyle', { lineHeight: null }).removeEmptyTextStyle().run()
},
}
},
})

View File

@@ -0,0 +1,86 @@
import { Extension } from '@tiptap/core'
import type { BackgroundColorOptions } from '../background-color/index.js'
import { BackgroundColor } from '../background-color/index.js'
import type { ColorOptions } from '../color/index.js'
import { Color } from '../color/index.js'
import type { FontFamilyOptions } from '../font-family/index.js'
import { FontFamily } from '../font-family/index.js'
import type { FontSizeOptions } from '../font-size/index.js'
import { FontSize } from '../font-size/index.js'
import type { LineHeightOptions } from '../line-height/index.js'
import { LineHeight } from '../line-height/index.js'
import type { TextStyleOptions } from '../text-style/index.js'
import { TextStyle } from '../text-style/index.js'
export interface TextStyleKitOptions {
/**
* If set to false, the background color extension will not be registered
* @example backgroundColor: false
*/
backgroundColor: Partial<BackgroundColorOptions> | false
/**
* If set to false, the color extension will not be registered
* @example color: false
*/
color: Partial<ColorOptions> | false
/**
* If set to false, the font family extension will not be registered
* @example fontFamily: false
*/
fontFamily: Partial<FontFamilyOptions> | false
/**
* If set to false, the font size extension will not be registered
* @example fontSize: false
*/
fontSize: Partial<FontSizeOptions> | false
/**
* If set to false, the line height extension will not be registered
* @example lineHeight: false
*/
lineHeight: Partial<LineHeightOptions> | false
/**
* If set to false, the text style extension will not be registered (required for other text style extensions)
* @example textStyle: false
*/
textStyle: Partial<TextStyleOptions> | false
}
/**
* The table kit is a collection of table editor extensions.
*
* It’s a good starting point for building your own table in Tiptap.
*/
export const TextStyleKit = Extension.create<TextStyleKitOptions>({
name: 'textStyleKit',
addExtensions() {
const extensions = []
if (this.options.backgroundColor !== false) {
extensions.push(BackgroundColor.configure(this.options.backgroundColor))
}
if (this.options.color !== false) {
extensions.push(Color.configure(this.options.color))
}
if (this.options.fontFamily !== false) {
extensions.push(FontFamily.configure(this.options.fontFamily))
}
if (this.options.fontSize !== false) {
extensions.push(FontSize.configure(this.options.fontSize))
}
if (this.options.lineHeight !== false) {
extensions.push(LineHeight.configure(this.options.lineHeight))
}
if (this.options.textStyle !== false) {
extensions.push(TextStyle.configure(this.options.textStyle))
}
return extensions
},
})

View File

@@ -0,0 +1,168 @@
import { Mark, mergeAttributes } from '@tiptap/core'
import type { TextStyleAttributes } from '../index.js'
export interface TextStyleOptions {
/**
* HTML attributes to add to the span element.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
/**
* When enabled, merges the styles of nested spans into the child span during HTML parsing.
* This prioritizes the style of the child span.
* Used when parsing content created in other editors.
* (Fix for ProseMirror's default behavior.)
* @default true
*/
mergeNestedSpanStyles: boolean
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
textStyle: {
/**
* Remove spans without inline style attributes.
* @example editor.commands.removeEmptyTextStyle()
*/
removeEmptyTextStyle: () => ReturnType
/**
* Toggle a text style
* @param attributes The text style attributes
* @example editor.commands.toggleTextStyle({ fontWeight: 'bold' })
*/
toggleTextStyle: (attributes?: TextStyleAttributes) => ReturnType
}
}
}
const MAX_FIND_CHILD_SPAN_DEPTH = 20
/**
* Returns all next child spans, either direct children or nested deeper
* but won't traverse deeper into child spans found, will only go MAX_FIND_CHILD_SPAN_DEPTH levels deep (default: 20)
*/
const findChildSpans = (element: HTMLElement, depth = 0): HTMLElement[] => {
const childSpans: HTMLElement[] = []
if (!element.children.length || depth > MAX_FIND_CHILD_SPAN_DEPTH) {
return childSpans
}
Array.from(element.children).forEach(child => {
if (child.tagName === 'SPAN') {
childSpans.push(child as HTMLElement)
} else if (child.children.length) {
childSpans.push(...findChildSpans(child as HTMLElement, depth + 1))
}
})
return childSpans
}
const mergeNestedSpanStyles = (element: HTMLElement) => {
if (!element.children.length) {
return
}
const childSpans = findChildSpans(element)
if (!childSpans) {
return
}
childSpans.forEach(childSpan => {
const childStyle = childSpan.getAttribute('style')
const closestParentSpanStyleOfChild = childSpan.parentElement
?.closest('span')
?.getAttribute('style')
childSpan.setAttribute('style', `${closestParentSpanStyleOfChild};${childStyle}`)
})
}
/**
* This extension allows you to create text styles. It is required by default
* for the `text-color` and `font-family` extensions.
* @see https://www.tiptap.dev/api/marks/text-style
*/
export const TextStyle = Mark.create<TextStyleOptions>({
name: 'textStyle',
priority: 101,
addOptions() {
return {
HTMLAttributes: {},
mergeNestedSpanStyles: true,
}
},
parseHTML() {
return [
{
tag: 'span',
consuming: false,
getAttrs: element => {
const hasStyles = (element as HTMLElement).hasAttribute('style')
if (!hasStyles) {
return false
}
if (this.options.mergeNestedSpanStyles) {
mergeNestedSpanStyles(element)
}
return {}
},
},
]
},
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
addCommands() {
return {
toggleTextStyle:
attributes =>
({ commands }) => {
return commands.toggleMark(this.name, attributes)
},
removeEmptyTextStyle:
() =>
({ tr }) => {
const { selection } = tr
// Gather all of the nodes within the selection range.
// We would need to go through each node individually
// to check if it has any inline style attributes.
// Otherwise, calling commands.unsetMark(this.name)
// removes everything from all the nodes
// within the selection range.
tr.doc.nodesBetween(selection.from, selection.to, (node, pos) => {
// Skip non-inline nodes, the text style only applies to spans
if (!node.isInline) {
return true
}
// Check if the node has no inline style attributes.
// Filter out non-`textStyle` marks.
if (
!node.marks
.filter(mark => mark.type === this.type)
.some(mark => Object.values(mark.attrs).some(value => !!value))
) {
// Proceed with the removal of the `textStyle` mark for this node only
tr.removeMark(pos, pos + node.nodeSize, this.type)
}
})
return true
},
}
},
})

View File

@@ -0,0 +1,48 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { BackgroundColor, TextStyle } from '@tiptap/extension-text-style'
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
describe('BackgroundColor commands', () => {
let editor: Editor
beforeEach(() => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document, Paragraph, Text, TextStyle, BackgroundColor],
content: '<p>Example Text</p>',
})
editor.commands.selectAll()
})
afterEach(() => {
editor.destroy()
})
it('sets the background color of the selected text', () => {
expect(editor.isActive('textStyle', { backgroundColor: '#958DF1' })).toBe(false)
editor.commands.setBackgroundColor('#958DF1')
expect(editor.isActive('textStyle', { backgroundColor: '#958DF1' })).toBe(true)
const span = editor.view.dom.querySelector('span')
expect(span?.textContent).toBe('Example Text')
expect(span?.style.backgroundColor).toBe('#958DF1')
})
it('removes the background color of the selected text', () => {
editor.commands.setBackgroundColor('#958DF1')
expect(editor.getHTML()).toContain('<span')
editor.commands.unsetBackgroundColor()
expect(editor.getHTML()).not.toContain('<span')
expect(editor.isActive('textStyle', { backgroundColor: '#958DF1' })).toBe(false)
})
it('exposes the active backgroundColor via getAttributes', () => {
editor.commands.setBackgroundColor('#958DF1')
expect(editor.getAttributes('textStyle').backgroundColor).toBe('#958DF1')
})
})

View File

@@ -0,0 +1,47 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { Color, TextStyle } from '@tiptap/extension-text-style'
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
describe('Color commands', () => {
let editor: Editor
beforeEach(() => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document, Paragraph, Text, TextStyle, Color],
content: '<p>Example Text</p>',
})
editor.commands.selectAll()
})
afterEach(() => {
editor.destroy()
})
it('sets the color of the selected text', () => {
expect(editor.isActive('textStyle', { color: '#958DF1' })).toBe(false)
editor.commands.setColor('#958DF1')
expect(editor.isActive('textStyle', { color: '#958DF1' })).toBe(true)
const span = editor.view.dom.querySelector('span')
expect(span?.textContent).toBe('Example Text')
expect(span?.style.color).toBe('#958DF1')
})
it('removes the color of the selected text', () => {
editor.commands.setColor('#958DF1')
expect(editor.getHTML()).toContain('<span')
editor.commands.unsetColor()
expect(editor.getHTML()).not.toContain('<span')
})
it('exposes the active color via getAttributes', () => {
editor.commands.setColor('#958DF1')
expect(editor.getAttributes('textStyle').color).toBe('#958DF1')
})
})

View File

@@ -0,0 +1,60 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { FontFamily, TextStyle } from '@tiptap/extension-text-style'
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
describe('FontFamily commands', () => {
let editor: Editor
beforeEach(() => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document, Paragraph, Text, TextStyle, FontFamily],
content: '<p>Example Text</p>',
})
editor.commands.selectAll()
})
afterEach(() => {
editor.destroy()
})
it('sets the font-family of the selected text', () => {
expect(editor.isActive('textStyle', { fontFamily: 'monospace' })).toBe(false)
editor.commands.setFontFamily('monospace')
expect(editor.isActive('textStyle', { fontFamily: 'monospace' })).toBe(true)
const span = editor.view.dom.querySelector('span')
expect(span?.textContent).toBe('Example Text')
expect((span as HTMLSpanElement | null)?.style.fontFamily).toBe('monospace')
})
it('removes the font-family of the selected text', () => {
editor.commands.setFontFamily('monospace')
expect(editor.getHTML()).toContain('<span')
editor.commands.unsetFontFamily()
expect(editor.getHTML()).not.toContain('<span')
})
it('allows CSS variables as a font-family', () => {
editor.commands.setFontFamily('var(--title-font-family)')
const span = editor.view.dom.querySelector('span')
expect(span?.style.fontFamily).toBe('var(--title-font-family)')
})
it('allows fonts containing multiple font families', () => {
editor.commands.setFontFamily('"Comic Sans MS", "Comic Sans"')
const span = editor.view.dom.querySelector('span')
expect(span?.style.fontFamily).toBe('"Comic Sans MS", "Comic Sans"')
})
it('allows fonts containing a space and number as a font-family', () => {
editor.commands.setFontFamily('"Exo 2"')
const span = editor.view.dom.querySelector('span')
expect(span?.style.fontFamily).toBe('"Exo 2"')
})
})

View File

@@ -0,0 +1,83 @@
import { Editor } from '@tiptap/core'
import Blockquote from '@tiptap/extension-blockquote'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { Color, FontSize, TextStyle } from '@tiptap/extension-text-style'
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
describe('FontSize commands', () => {
let editor: Editor
beforeEach(() => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document, Paragraph, Text, TextStyle, FontSize],
content: '<p>Example Text</p>',
})
editor.commands.selectAll()
})
afterEach(() => {
editor.destroy()
})
it('sets the font-size of the selected text', () => {
expect(editor.isActive('textStyle', { fontSize: '28px' })).toBe(false)
editor.commands.setFontSize('28px')
expect(editor.isActive('textStyle', { fontSize: '28px' })).toBe(true)
const span = editor.view.dom.querySelector('span')
expect(span?.textContent).toBe('Example Text')
expect(span?.style.fontSize).toBe('28px')
})
it('removes the font-size of the selected text', () => {
editor.commands.setFontSize('28px')
expect(editor.view.dom.querySelector('span')?.style.fontSize).toBe('28px')
editor.commands.unsetFontSize()
expect(editor.getHTML()).not.toContain('<span')
})
})
describe('FontSize commands inside a wrapper node', () => {
let editor: Editor
const editorWith = (content: string) => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document, Paragraph, Text, Blockquote, TextStyle, Color, FontSize],
content,
})
editor.commands.selectAll()
return editor
}
afterEach(() => {
editor.destroy()
})
it('keeps the other text styles when unsetting inside a blockquote', () => {
editorWith(
'<blockquote><p><span style="color: #ff0000; font-size: 28px">Example Text</span></p></blockquote>',
)
editor.commands.unsetFontSize()
expect(editor.getHTML()).toContain('color: #ff0000')
expect(editor.getHTML()).not.toContain('font-size')
})
it('keeps the other text styles when unsetting inside a paragraph', () => {
editorWith('<p><span style="color: #ff0000; font-size: 28px">Example Text</span></p>')
editor.commands.unsetFontSize()
expect(editor.getHTML()).toContain('color: #ff0000')
expect(editor.getHTML()).not.toContain('font-size')
})
})

View File

@@ -0,0 +1,50 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { LineHeight, TextStyle } from '@tiptap/extension-text-style'
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
describe('LineHeight commands', () => {
let editor: Editor
beforeEach(() => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document, Paragraph, Text, TextStyle, LineHeight],
content: '<p>Example Text</p>',
})
editor.commands.selectAll()
})
afterEach(() => {
editor.destroy()
})
const cases = [
{ value: '1.5', style: '1.5' },
{ value: '2.0', style: '2' },
{ value: '4.0', style: '4' },
]
cases.forEach(({ value, style }) => {
it(`sets line-height ${value} for the selected text`, () => {
expect(editor.isActive('textStyle', { lineHeight: value })).toBe(false)
editor.commands.toggleTextStyle({ lineHeight: value })
expect(editor.isActive('textStyle', { lineHeight: value })).toBe(true)
const span = editor.view.dom.querySelector('span')
expect(span?.textContent).toBe('Example Text')
expect(span?.style.lineHeight).toBe(style)
})
})
it('removes the line-height of the selected text', () => {
editor.commands.toggleTextStyle({ lineHeight: '1.5' })
expect(editor.view.dom.querySelector('span')?.style.lineHeight).toBe('1.5')
editor.commands.unsetLineHeight()
expect(editor.getHTML()).not.toContain('<span')
})
})

View File

@@ -0,0 +1,124 @@
import { Editor } from '@tiptap/core'
import Bold from '@tiptap/extension-bold'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { TextStyleKit } from '@tiptap/extension-text-style'
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
const demoContent = `
<p><span>This has a &lt;span&gt; tag without a style attribute, so it's thrown away.</span></p>
<p><span style="">But this one is wrapped in a &lt;span&gt; tag with an inline style attribute, so it's kept - even if it's empty for now.</span></p>
<p>--- merge nested span styles option enabled ---</p>
<p>
<span style="color: #FF0000;">
<span style="font-family: serif;">
red serif
</span>
</span>
</p>
<p>
<span style="color: #FF0000;">
<span style="font-family: serif;">
<span style="color: #0000FF;">
blue serif
</span>
</span>
</span>
</p>
<p>
<span style="color: #00FF00;">
<span style="font-family: serif;">green serif </span>
<span style="font-family: serif;color: #FF0000;">red serif</span>
</span>
</p>
<p>
<span>
plain
<span style="color: #0000FF;">blue</span>
plain
<span style="color: #00FF00;">
green
<span style="font-family: serif;">green serif</span>
</span>
plain
</span>
</p>
<p>
<span style="color: #0000FF;">
blue
<span style="color: #00FF00;">
green
<span style="font-family: serif;">
green serif
<span style="color: #0000FF;">blue serif</span>
</span>
</span>
</span>
</p>
`
describe('TextStyleKit nested span merge', () => {
let editor: Editor
beforeEach(() => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document, Paragraph, Text, TextStyleKit, Bold],
content: demoContent,
})
})
afterEach(() => {
editor.destroy()
})
it('merges styles of a span with one nested child span into a single span', () => {
const spans = editor.view.dom.querySelectorAll('p:nth-child(4) > span')
expect(spans.length).toBe(1)
expect(spans[0].textContent?.trim()).toBe('red serif')
expect(spans[0].getAttribute('style')).toBe('color: #FF0000; font-family: serif;')
})
it('merges styles of multiple nested spans into the innermost descendant', () => {
const spans = editor.view.dom.querySelectorAll('p:nth-child(5) > span')
expect(spans.length).toBe(1)
expect(spans[0].textContent?.trim()).toBe('blue serif')
expect(spans[0].getAttribute('style')).toBe('color: #0000FF; font-family: serif;')
})
it('merges parent styles into each sibling descendant span', () => {
const spans = editor.view.dom.querySelectorAll('p:nth-child(6) > span')
expect(spans.length).toBe(2)
expect(spans[0].textContent?.trim()).toBe('green serif')
expect(spans[0].getAttribute('style')).toBe('color: #00FF00; font-family: serif;')
expect(spans[1].textContent?.trim()).toBe('red serif')
expect(spans[1].getAttribute('style')).toBe('color: #FF0000; font-family: serif;')
})
it('keeps descendant spans intact when the parent span has no style', () => {
const spans = editor.view.dom.querySelectorAll('p:nth-child(7) > span')
expect(spans.length).toBe(4)
expect(spans[0].textContent?.trim()).toBe('blue')
expect(spans[0].getAttribute('style')).toBe('color: #0000FF;')
expect(spans[1].textContent?.trim()).toBe('green')
expect(spans[1].getAttribute('style')).toBe('color: #00FF00;')
expect(spans[2].textContent?.trim()).toBe('green serif')
expect(spans[2].getAttribute('style')).toBe('color: #00FF00; font-family: serif;')
})
it('merges parent styles into descendants while preserving root text spans', () => {
const spans = editor.view.dom.querySelectorAll('p:nth-child(8) > span')
expect(spans.length).toBe(4)
expect(spans[0].textContent?.trim()).toBe('blue')
expect(spans[0].getAttribute('style')).toBe('color: #0000FF;')
expect(spans[1].textContent?.trim()).toBe('green')
expect(spans[1].getAttribute('style')).toBe('color: #00FF00;')
expect(spans[2].textContent?.trim()).toBe('green serif')
expect(spans[2].getAttribute('style')).toBe('color: #00FF00; font-family: serif;')
expect(spans[3].textContent?.trim()).toBe('blue serif')
expect(spans[3].getAttribute('style')).toBe('color: #0000FF; font-family: serif;')
})
})

View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'vite-plus'
import { tsupCompatibleExtensions } from '../../pack.config.mjs'
const entries = [
'src/background-color/index.ts',
'src/color/index.ts',
'src/font-family/index.ts',
'src/font-size/index.ts',
'src/line-height/index.ts',
'src/text-style/index.ts',
'src/text-style-kit/index.ts',
'src/index.ts',
]
export default defineConfig({
pack: entries.map(entry => ({
entry: [entry],
tsconfig: '../../tsconfig.build.json',
outDir: `dist${entry.replace('src', '').split('/').slice(0, -1).join('/')}`,
dts: { sourcemap: true },
sourcemap: true,
target: 'es2019',
format: ['esm', 'cjs'],
deps: { neverBundle: [/^[^./]/] },
outExtensions: tsupCompatibleExtensions,
})),
})