Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/olive-penguins-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tiptap/core': patch
'@tiptap/extension-text-style': patch
'@tiptap/extension-highlight': patch
---

Fix `"` HTML entity encoding in `getHTML()` output for inline style attributes. Adds a `getStyleProperty` utility to `@tiptap/core` and migrates `Color`, `BackgroundColor`, `FontFamily`, `FontSize`, `LineHeight`, and `Highlight` extensions to use it (#7016)
52 changes: 52 additions & 0 deletions packages/core/__tests__/getStyleProperty.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { getStyleProperty } from '@tiptap/core'
import { describe, expect, it } from 'vitest'

describe('getStyleProperty', () => {
it('returns null when the element has no style attribute', () => {
const el = document.createElement('span')

expect(getStyleProperty(el, 'font-family')).toBeNull()
})

it('returns null when the requested property is not declared', () => {
const el = document.createElement('span')
el.setAttribute('style', 'color: red')

expect(getStyleProperty(el, 'font-family')).toBeNull()
})

it('returns the raw value preserving original formatting', () => {
const el = document.createElement('span')
el.setAttribute('style', "font-family: 'Comic Sans MS', cursive")

expect(getStyleProperty(el, 'font-family')).toBe("'Comic Sans MS', cursive")
})

it('returns the last declaration when the property is declared multiple times', () => {
const el = document.createElement('span')
el.setAttribute('style', 'color: red; color: blue')

expect(getStyleProperty(el, 'color')).toBe('blue')
})

it('matches the property name case-insensitively', () => {
const el = document.createElement('span')
el.setAttribute('style', 'FONT-FAMILY: Inter')

expect(getStyleProperty(el, 'font-family')).toBe('Inter')
})

it('preserves colons that appear inside the value (e.g. URLs)', () => {
const el = document.createElement('span')
el.setAttribute('style', "background-image: url('https://example.com/img.png')")

expect(getStyleProperty(el, 'background-image')).toBe("url('https://example.com/img.png')")
})

it('ignores trailing semicolons and extra whitespace', () => {
const el = document.createElement('span')
el.setAttribute('style', ' font-size: 16px ; ')

expect(getStyleProperty(el, 'font-size')).toBe('16px')
})
})
52 changes: 52 additions & 0 deletions packages/core/src/utilities/getStyleProperty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Read a CSS property value directly from an element's raw inline `style`
* attribute, bypassing the CSSOM (e.g. `element.style.fontFamily`) which
* canonicalizes values and can change formatting. The original format is
* preserved (quotes, hex vs rgb, etc.).
*
* When a property is declared more than once, the last declaration wins —
* this matches CSS cascade order and is useful when nested spans are merged
* and the child's value should take priority.
*
* Property name comparison is case-insensitive.
*
* @param element - The element whose `style` attribute should be read.
* @param propertyName - The CSS property name (e.g. `font-family`).
* @returns The raw value string, or `null` if the property is not present.
*
* @example
* ```ts
* parseHTML: element => getStyleProperty(element, 'font-family')
* ```
*/
export function getStyleProperty(element: HTMLElement, propertyName: string): string | null {
const styleAttr = element.getAttribute('style')

if (!styleAttr) {
return null
}

const decls = styleAttr
.split(';')
.map(decl => decl.trim())
.filter(Boolean)

const target = propertyName.toLowerCase()

for (let i = decls.length - 1; i >= 0; i -= 1) {
const decl = decls[i]
const colonIndex = decl.indexOf(':')

if (colonIndex === -1) {
continue
}

const prop = decl.slice(0, colonIndex).trim().toLowerCase()

if (prop === target) {
return decl.slice(colonIndex + 1).trim()
}
}

return null
}
1 change: 1 addition & 0 deletions packages/core/src/utilities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from './elementFromString.js'
export * from './escapeForRegEx.js'
export * from './findDuplicates.js'
export * from './fromString.js'
export * from './getStyleProperty.js'
export * from './htmlEntities.js'
export * from './isAndroid.js'
export * from './isEmptyObject.js'
Expand Down
11 changes: 9 additions & 2 deletions packages/extension-highlight/src/highlight.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Mark, markInputRule, markPasteRule, mergeAttributes } from '@tiptap/core'
import { getStyleProperty, Mark, markInputRule, markPasteRule, mergeAttributes } from '@tiptap/core'

export interface HighlightOptions {
/**
Expand Down Expand Up @@ -72,7 +72,14 @@ export const Highlight = Mark.create<HighlightOptions>({
return {
color: {
default: null,
parseHTML: element => element.getAttribute('data-color') || element.style.backgroundColor,
// Prefer `data-color` (set by our own `renderHTML`) for lossless
// round-trips. Otherwise parse the raw inline `style` attribute so
// the original color format (e.g. `#rrggbb`) is preserved instead of
// the canonicalized `rgb(...)` value from `element.style.backgroundColor`.
parseHTML: element =>
element.getAttribute('data-color') ||
getStyleProperty(element, 'background-color') ||
element.style.backgroundColor,
renderHTML: attributes => {
if (!attributes.color) {
return {}
Expand Down
89 changes: 89 additions & 0 deletions packages/extension-text-style/__tests__/font-family.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { FontFamily } from '@tiptap/extension-text-style'
import { describe, expect, it } from 'vitest'

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')
})
})
35 changes: 35 additions & 0 deletions packages/extension-text-style/__tests__/font-size.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { FontSize } from '@tiptap/extension-text-style'
import { describe, expect, it } from 'vitest'

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')
})
})
35 changes: 35 additions & 0 deletions packages/extension-text-style/__tests__/line-height.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { LineHeight } from '@tiptap/extension-text-style'
import { describe, expect, it } from 'vitest'

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')
})
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import '../text-style/index.js'

import { Extension } from '@tiptap/core'
import { Extension, getStyleProperty } from '@tiptap/core'

export type BackgroundColorOptions = {
/**
Expand Down Expand Up @@ -58,31 +58,11 @@ export const BackgroundColor = Extension.create<BackgroundColorOptions>({
backgroundColor: {
default: null,
parseHTML: element => {
// Prefer the raw inline `style` attribute so we preserve
// the original format (e.g. `#rrggbb`) instead of the
// computed `rgb(...)` value returned by `element.style.backgroundColor`.
// When nested spans are merged the style attribute may contain
// multiple `background-color:` declarations (parent;child). We should pick
// the last declaration so the child's background color takes priority.
const styleAttr = element.getAttribute('style')
if (styleAttr) {
const decls = styleAttr
.split(';')
.map(s => s.trim())
.filter(Boolean)
for (let i = decls.length - 1; i >= 0; i -= 1) {
const parts = decls[i].split(':')
if (parts.length >= 2) {
const prop = parts[0].trim().toLowerCase()
const val = parts.slice(1).join(':').trim()
if (prop === 'background-color') {
return val.replace(/['"]+/g, '')
}
}
}
}

return element.style.backgroundColor?.replace(/['"]+/g, '')
// 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) {
Expand Down
32 changes: 6 additions & 26 deletions packages/extension-text-style/src/color/color.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import '../text-style/index.js'

import { Extension } from '@tiptap/core'
import { Extension, getStyleProperty } from '@tiptap/core'

export type ColorOptions = {
/**
Expand Down Expand Up @@ -58,31 +58,11 @@ export const Color = Extension.create<ColorOptions>({
color: {
default: null,
parseHTML: element => {
// Prefer the raw inline `style` attribute so we preserve
// the original format (e.g. `#rrggbb`) instead of the
// computed `rgb(...)` value returned by `element.style.color`.
// When nested spans are merged the style attribute may contain
// multiple `color:` declarations (parent;child). We should pick
// the last declaration so the child's color takes priority.
const styleAttr = element.getAttribute('style')
if (styleAttr) {
const decls = styleAttr
.split(';')
.map(s => s.trim())
.filter(Boolean)
for (let i = decls.length - 1; i >= 0; i -= 1) {
const parts = decls[i].split(':')
if (parts.length >= 2) {
const prop = parts[0].trim().toLowerCase()
const val = parts.slice(1).join(':').trim()
if (prop === 'color') {
return val.replace(/['"]+/g, '')
}
}
}
}

return element.style.color?.replace(/['"]+/g, '')
// 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) {
Expand Down
Loading
Loading