diff --git a/.playwright/screenshots/custom-style-colors-visual.png b/.playwright/screenshots/custom-style-colors-visual.png new file mode 100644 index 00000000..2b55288e Binary files /dev/null and b/.playwright/screenshots/custom-style-colors-visual.png differ diff --git a/.playwright/tests/customStyleColors.spec.ts b/.playwright/tests/customStyleColors.spec.ts new file mode 100644 index 00000000..0c34b78b --- /dev/null +++ b/.playwright/tests/customStyleColors.spec.ts @@ -0,0 +1,287 @@ +import { test, expect, type Page } from '@playwright/test'; + +import { + editorLocator, + getSerializedHtml, + gotoVisualRegression, + setEditorHtml, +} from '../helpers/visual-regression'; +import { toolbarButton } from '../helpers/toolbar'; + +function textColorButton(page: Page) { + return page.locator('[data-testid="toolbar-text-color"]'); +} + +function bgColorButton(page: Page) { + return page.locator('[data-testid="toolbar-bg-color"]'); +} + +function colorSwatch(page: Page, color: string) { + return page.locator( + `[data-testid="toolbar-color-swatch-${color.replace('#', '')}"]` + ); +} + +function colorSwatchClear(page: Page) { + return page.locator('[data-testid="toolbar-color-swatch-clear"]'); +} + +async function applyTextColor(page: Page, color: string) { + await textColorButton(page).click(); + await colorSwatch(page, color).click(); +} + +async function clearTextColor(page: Page) { + await textColorButton(page).click(); + await colorSwatchClear(page).click(); +} + +async function applyBgColor(page: Page, color: string) { + await bgColorButton(page).click(); + await colorSwatch(page, color).click(); +} + +async function clearBgColor(page: Page) { + await bgColorButton(page).click(); + await colorSwatchClear(page).click(); +} + +const ROUND_TRIP_CASES: { name: string; input: string; expected: string }[] = [ + { + name: 'foreground color only', + input: '

Red text

', + expected: + '

Red text

', + }, + { + name: 'background color only', + input: + '

Yellow bg

', + expected: + '

Yellow bg

', + }, + { + name: 'foreground and background color', + input: + '

Both

', + expected: + '

Both

', + }, + { + name: '8-digit hex background (transparent)', + input: + '

25% green bg

', + expected: + '

25% green bg

', + }, + { + name: '8-digit hex foreground (transparent)', + input: + '

50% blue text

', + expected: + '

50% blue text

', + }, + { + name: 'color inside heading', + input: + '
Black on green
', + expected: + '
Black on green
', + }, + { + name: 'color wraps bold mark', + input: + '

Bold red

', + expected: + '

Bold red

', + }, + { + name: 'multiple colored spans in one paragraph', + input: + '

Red plain Blue

', + expected: + '

Red plain Blue

', + }, +]; + +test.describe('custom style colors - HTML serialization', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + for (const { name, input, expected } of ROUND_TRIP_CASES) { + test(name, async ({ page }) => { + await setEditorHtml(page, input); + await expect.poll(async () => getSerializedHtml(page)).toBe(expected); + }); + } +}); + +test('custom style colors visual regression', async ({ page }) => { + await gotoVisualRegression(page); + + const html = [ + '', + '

Standard 6-digit hex text

', + '

White text on black background

', + '

25% transparent green background

', + '

50% transparent blue text

', + '

Red 3-digit shorthand text

', + '
Black text on green
', + '', + ].join(''); + + await setEditorHtml(page, html); + + const editor = editorLocator(page); + await expect(editor).toHaveScreenshot('custom-style-colors-visual.png'); +}); + +test.describe('custom style colors - toolbar interaction', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + test('apply foreground color then type text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyTextColor(page, '#FF0000'); + await editor.pressSequentially('Red text', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + '

Red text

' + ); + }); + + test('clear foreground color stops coloring new text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyTextColor(page, '#FF0000'); + await editor.pressSequentially('Red', { delay: 80 }); + await clearTextColor(page); + await editor.pressSequentially(' plain', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + '

Red plain

' + ); + }); + + test('apply background color then type text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyBgColor(page, '#FFFF00'); + await editor.pressSequentially('Yellow back', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + '

Yellow back

' + ); + }); + + test('clear background color stops coloring new text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyBgColor(page, '#FFFF00'); + await editor.pressSequentially('Yellow', { delay: 80 }); + await clearBgColor(page); + await editor.pressSequentially(' plain', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + '

Yellow plain

' + ); + }); + + test('apply foreground and background color together', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyTextColor(page, '#FF0000'); + await applyBgColor(page, '#FFFF00'); + await editor.pressSequentially('Red+Yellow', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + '

Red+Yellow

' + ); + }); + + test('foreground color with bold', async ({ page }) => { + const editor = editorLocator(page); + const boldBtn = toolbarButton(page, 'bold'); + await editor.click(); + + await boldBtn.click(); + await applyTextColor(page, '#FF0000'); + await editor.pressSequentially('Bold red', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + '

Bold red

' + ); + }); + + test('background color with italic', async ({ page }) => { + const editor = editorLocator(page); + const italicBtn = toolbarButton(page, 'italic'); + await editor.click(); + + await italicBtn.click(); + await applyBgColor(page, '#FFFF00'); + await editor.pressSequentially('Italic yellow back', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + '

Italic yellow back

' + ); + }); + + test('toolbar text-color button shows active swatch when color is set', async ({ + page, + }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyTextColor(page, '#FF0000'); + + // Re-open the picker – the chosen swatch should be marked as active + await textColorButton(page).click(); + await expect(colorSwatch(page, '#FF0000')).toHaveClass( + /toolbar-color-swatch--active/ + ); + + // Close the picker + await textColorButton(page).click(); + }); + + test('toolbar bg-color button shows active swatch when color is set', async ({ + page, + }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyBgColor(page, '#FFFF00'); + + // Re-open the picker – the chosen swatch should be marked as active + await bgColorButton(page).click(); + await expect(colorSwatch(page, '#FFFF00')).toHaveClass( + /toolbar-color-swatch--active/ + ); + + // Close the picker + await bgColorButton(page).click(); + }); +}); diff --git a/apps/example-web/src/components/Toolbar.css b/apps/example-web/src/components/Toolbar.css index 7b86fb8d..2186d24d 100644 --- a/apps/example-web/src/components/Toolbar.css +++ b/apps/example-web/src/components/Toolbar.css @@ -77,3 +77,65 @@ .toolbar-btn:focus-visible { outline: none; } + +.toolbar-wrapper { + display: flex; + flex-direction: column; + width: 100%; +} + +.toolbar-color-btn { + flex-direction: column; + gap: 2px; +} + +.toolbar-color-label { + font-size: 15px; + font-weight: 700; + line-height: 1; + color: #fff; +} + +.toolbar-color-indicator { + display: block; + width: 20px; + height: 5px; + border-radius: 2px; + border: 1px solid; +} + +.toolbar-color-picker { + display: flex; + flex-wrap: wrap; + padding: 8px; + gap: 6px; + background: rgba(0, 26, 114, 0.95); +} + +.toolbar-color-swatch { + width: 28px; + height: 28px; + border-radius: 4px; + border: 2px solid transparent; + cursor: pointer; + box-sizing: border-box; +} + +.toolbar-color-swatch--clear { + background: transparent; + color: #fff; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + border-color: rgba(255, 255, 255, 0.4); +} + +.toolbar-color-swatch--active { + border-color: #fff; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.6); +} + +.toolbar-color-swatch:focus-visible { + outline: none; +} diff --git a/apps/example-web/src/components/Toolbar.tsx b/apps/example-web/src/components/Toolbar.tsx index 42241fae..e68f44a8 100644 --- a/apps/example-web/src/components/Toolbar.tsx +++ b/apps/example-web/src/components/Toolbar.tsx @@ -3,9 +3,28 @@ import type { EnrichedTextInputInstance, OnChangeStateEvent, } from 'react-native-enriched-html'; -import type { RefObject } from 'react'; +import { useState, type RefObject } from 'react'; import { useDragScroll } from '../hooks/useDragScroll'; +const COLORS = [ + '#808080', + '#FF0000', + '#FF6600', + '#FFFF00', + '#00FF00', + '#008000', + '#00FFFF', + '#0000FF', + '#800080', + '#FF00FF', + '#FF69B4', + '#A52A2A', + '#FFA500', + '#ADD8E6', +]; + +type OpenPicker = 'text-color' | 'bg-color' | null; + interface ToolbarProps { editorRef: RefObject; state: OnChangeStateEvent | null; @@ -61,6 +80,27 @@ export function Toolbar({ }: ToolbarProps) { const s = state; const dragScroll = useDragScroll(); + const [openPicker, setOpenPicker] = useState(null); + + const activeFgColor = s?.customStyle.foregroundColor ?? ''; + const activeBgColor = s?.customStyle.backgroundColor ?? ''; + + const handleSelectFgColor = (color: string) => { + editorRef.current?.setStyle({ foregroundColor: color }); + setOpenPicker(null); + }; + const handleClearFgColor = () => { + editorRef.current?.setStyle({ foregroundColor: null }); + setOpenPicker(null); + }; + const handleSelectBgColor = (color: string) => { + editorRef.current?.setStyle({ backgroundColor: color }); + setOpenPicker(null); + }; + const handleClearBgColor = () => { + editorRef.current?.setStyle({ backgroundColor: null }); + setOpenPicker(null); + }; const toolbarItems = [ { @@ -203,23 +243,117 @@ export function Toolbar({ }[]; return ( -
-
- {toolbarItems.map((item) => ( - { - item.onPress(editorRef.current); +
+
+
+ {toolbarItems.map((item) => ( + { + item.onPress(editorRef.current); + }} + /> + ))} + + +
+ - ); } diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index 384459cc..2b810e1c 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -60,6 +60,7 @@ import { EnrichedUnorderedList } from './formats/EnrichedUnorderedList'; import { EnrichedOrderedList } from './formats/EnrichedOrderedList'; import { EnrichedCheckboxItem } from './formats/EnrichedCheckboxItem'; import { EnrichedCheckboxList } from './formats/EnrichedCheckboxList'; +import { EnrichedCustomStyle } from './formats/EnrichedCustomStyle'; import { StripBoldInStyledHeadingsPlugin } from './pmPlugins/StripBoldInStyledHeadingsPlugin'; import { StrictMarksPlugin } from './pmPlugins/StrictMarksPlugin'; import { MergeAdjacentSameKindBlocksPlugin } from './pmPlugins/MergeAdjacentSameKindBlocksPlugin'; @@ -215,6 +216,7 @@ export const EnrichedTextInput = ({ EnrichedUnorderedList, EnrichedOrderedList, EnrichedCheckboxList, + EnrichedCustomStyle, StripMarksInCodeBlockPlugin, StripMarksOnImagePlugin, StripBoldInStyledHeadingsPlugin.configure({ @@ -368,7 +370,29 @@ export const EnrichedTextInput = ({ measureLayout: () => {}, setNativeProps: () => {}, setTextAlignment: () => {}, - setStyle: () => {}, + setStyle: (customStyle) => { + const current = editor.getAttributes('customStyle'); + + const resolvedColor = + 'foregroundColor' in customStyle + ? (customStyle.foregroundColor ?? null) + : current.foregroundColor; + const resolvedBg = + 'backgroundColor' in customStyle + ? (customStyle.backgroundColor ?? null) + : (current.backgroundColor ?? null); + + runFocused(editor, (c) => { + if (!resolvedColor && !resolvedBg) { + return c.unsetCustomStyle(); + } + + return c.setCustomStyle({ + foregroundColor: resolvedColor, + backgroundColor: resolvedBg, + }); + }); + }, }), [editor] ); diff --git a/src/web/formats/EnrichedCustomStyle.ts b/src/web/formats/EnrichedCustomStyle.ts new file mode 100644 index 00000000..7602111b --- /dev/null +++ b/src/web/formats/EnrichedCustomStyle.ts @@ -0,0 +1,94 @@ +import { Mark } from '@tiptap/core'; + +declare module '@tiptap/core' { + interface Commands { + customStyle: { + setCustomStyle: (attrs: { + foregroundColor?: string | null; + backgroundColor?: string | null; + }) => ReturnType; + unsetCustomStyle: () => ReturnType; + }; + } +} + +export const EnrichedCustomStyle = Mark.create({ + name: 'customStyle', + + // Priority must be higher than inline marks (code: 1000, mention: 1000) so + // the inline marks will override the customStyle. + priority: 1001, + + addAttributes() { + return { + foregroundColor: { + default: null, + parseHTML: (el: HTMLElement) => el.style.color || null, + }, + backgroundColor: { + default: null, + parseHTML: (el: HTMLElement) => el.style.backgroundColor || null, + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'span', + getAttrs: (el: HTMLElement) => { + if (!el.style.color && !el.style.backgroundColor) { + return false; + } + // let addAttributes handle the actual parsing + return null; + }, + }, + ]; + }, + + renderHTML({ mark }) { + const parts: string[] = []; + if (mark.attrs.foregroundColor) { + parts.push(`color: ${mark.attrs.foregroundColor}`); + } + if (mark.attrs.backgroundColor) { + parts.push(`background-color: ${mark.attrs.backgroundColor}`); + } + return ['span', { style: parts.join('; ') }, 0]; + }, + + addCommands() { + return { + setCustomStyle: + (attrs) => + ({ chain, editor }) => { + const current = editor.getAttributes('customStyle'); + const resolvedColor = + 'foregroundColor' in attrs + ? attrs.foregroundColor + : current.foregroundColor; + const resolvedBg = + 'backgroundColor' in attrs + ? attrs.backgroundColor + : current.backgroundColor; + + if (!resolvedColor && !resolvedBg) { + return chain().unsetMark('customStyle').run(); + } + + return chain() + .setMark('customStyle', { + foregroundColor: resolvedColor ?? null, + backgroundColor: resolvedBg ?? null, + }) + .run(); + }, + + unsetCustomStyle: + () => + ({ chain }) => + chain().unsetMark('customStyle').run(), + }; + }, +}); diff --git a/src/web/normalization/colorNormalizer.ts b/src/web/normalization/colorNormalizer.ts new file mode 100644 index 00000000..ca043d08 --- /dev/null +++ b/src/web/normalization/colorNormalizer.ts @@ -0,0 +1,225 @@ +const CSS_NAMED_COLORS: Record = { + aliceblue: '#F0F8FFFF', + antiquewhite: '#FAEBD7FF', + aqua: '#00FFFFFF', + aquamarine: '#7FFFD4FF', + azure: '#F0FFFFFF', + beige: '#F5F5DCFF', + bisque: '#FFE4C4FF', + black: '#000000FF', + blanchedalmond: '#FFEBCDFF', + blue: '#0000FFFF', + blueviolet: '#8A2BE2FF', + brown: '#A52A2AFF', + burlywood: '#DEB887FF', + cadetblue: '#5F9EA0FF', + chartreuse: '#7FFF00FF', + chocolate: '#D2691EFF', + coral: '#FF7F50FF', + cornflowerblue: '#6495EDFF', + cornsilk: '#FFF8DCFF', + crimson: '#DC143CFF', + cyan: '#00FFFFFF', + darkblue: '#00008BFF', + darkcyan: '#008B8BFF', + darkgoldenrod: '#B8860BFF', + darkgray: '#A9A9A9FF', + darkgrey: '#A9A9A9FF', + darkgreen: '#006400FF', + darkkhaki: '#BDB76BFF', + darkmagenta: '#8B008BFF', + darkolivegreen: '#556B2FFF', + darkorange: '#FF8C00FF', + darkorchid: '#9932CCFF', + darkred: '#8B0000FF', + darksalmon: '#E9967AFF', + darkseagreen: '#8FBC8FFF', + darkslateblue: '#483D8BFF', + darkslategray: '#2F4F4FFF', + darkslategrey: '#2F4F4FFF', + darkturquoise: '#00CED1FF', + darkviolet: '#9400D3FF', + deeppink: '#FF1493FF', + deepskyblue: '#00BFFFFF', + dimgray: '#696969FF', + dimgrey: '#696969FF', + dodgerblue: '#1E90FFFF', + firebrick: '#B22222FF', + floralwhite: '#FFFAF0FF', + forestgreen: '#228B22FF', + fuchsia: '#FF00FFFF', + gainsboro: '#DCDCDCFF', + ghostwhite: '#F8F8FFFF', + gold: '#FFD700FF', + goldenrod: '#DAA520FF', + gray: '#808080FF', + grey: '#808080FF', + green: '#008000FF', + greenyellow: '#ADFF2FFF', + honeydew: '#F0FFF0FF', + hotpink: '#FF69B4FF', + indianred: '#CD5C5CFF', + indigo: '#4B0082FF', + ivory: '#FFFFF0FF', + khaki: '#F0E68CFF', + lavender: '#E6E6FAFF', + lavenderblush: '#FFF0F5FF', + lawngreen: '#7CFC00FF', + lemonchiffon: '#FFFACDFF', + lightblue: '#ADD8E6FF', + lightcoral: '#F08080FF', + lightcyan: '#E0FFFFFF', + lightgoldenrodyellow: '#FAFAD2FF', + lightgray: '#D3D3D3FF', + lightgrey: '#D3D3D3FF', + lightgreen: '#90EE90FF', + lightpink: '#FFB6C1FF', + lightsalmon: '#FFA07AFF', + lightseagreen: '#20B2AAFF', + lightskyblue: '#87CEFAFF', + lightslategray: '#778899FF', + lightslategrey: '#778899FF', + lightsteelblue: '#B0C4DEFF', + lightyellow: '#FFFFE0FF', + lime: '#00FF00FF', + limegreen: '#32CD32FF', + linen: '#FAF0E6FF', + magenta: '#FF00FFFF', + maroon: '#800000FF', + mediumaquamarine: '#66CDAAFF', + mediumblue: '#0000CDFF', + mediumorchid: '#BA55D3FF', + mediumpurple: '#9370D8FF', + mediumseagreen: '#3CB371FF', + mediumslateblue: '#7B68EEFF', + mediumspringgreen: '#00FA9AFF', + mediumturquoise: '#48D1CCFF', + mediumvioletred: '#C71585FF', + midnightblue: '#191970FF', + mintcream: '#F5FFFAFF', + mistyrose: '#FFE4E1FF', + moccasin: '#FFE4B5FF', + navajowhite: '#FFDEADFF', + navy: '#000080FF', + oldlace: '#FDF5E6FF', + olive: '#808000FF', + olivedrab: '#6B8E23FF', + orange: '#FFA500FF', + orangered: '#FF4500FF', + orchid: '#DA70D6FF', + palegoldenrod: '#EEE8AAFF', + palegreen: '#98FB98FF', + paleturquoise: '#AFEEEEFF', + palevioletred: '#D87093FF', + papayawhip: '#FFEFD5FF', + peachpuff: '#FFDAB9FF', + peru: '#CD853FFF', + pink: '#FFC0CBFF', + plum: '#DDA0DDFF', + powderblue: '#B0E0E6FF', + purple: '#800080FF', + rebeccapurple: '#663399FF', + red: '#FF0000FF', + rosybrown: '#BC8F8FFF', + royalblue: '#4169E1FF', + saddlebrown: '#8B4513FF', + salmon: '#FA8072FF', + sandybrown: '#F4A460FF', + seagreen: '#2E8B57FF', + seashell: '#FFF5EEFF', + sienna: '#A0522DFF', + silver: '#C0C0C0FF', + skyblue: '#87CEEBFF', + slateblue: '#6A5ACDFF', + slategray: '#708090FF', + slategrey: '#708090FF', + snow: '#FFFAFAFF', + springgreen: '#00FF7FFF', + steelblue: '#4682B4FF', + tan: '#D2B48CFF', + teal: '#008080FF', + thistle: '#D8BFD8FF', + tomato: '#FF6347FF', + turquoise: '#40E0D0FF', + violet: '#EE82EEFF', + wheat: '#F5DEB3FF', + white: '#FFFFFFFF', + whitesmoke: '#F5F5F5FF', + yellow: '#FFFF00FF', + yellowgreen: '#9ACD32FF', +}; + +export function normalizeColorToHex( + value: string | null | undefined +): string | null { + if (!value) return null; + let str = value.trim().toLowerCase(); + + // Handle Named Colors + if (CSS_NAMED_COLORS[str] !== undefined) { + str = CSS_NAMED_COLORS[str]!.toLowerCase(); + } + + // Handle Hex (#FFF, #RGBA, #RRGGBB, #RRGGBBAA) + if (str.startsWith('#')) { + const hex = str.substring(1); + let r, + g, + b, + a = 'ff'; + + if (hex.length === 3) { + r = hex.charAt(0) + hex.charAt(0); + g = hex.charAt(1) + hex.charAt(1); + b = hex.charAt(2) + hex.charAt(2); + } else if (hex.length === 4) { + r = hex.charAt(0) + hex.charAt(0); + g = hex.charAt(1) + hex.charAt(1); + b = hex.charAt(2) + hex.charAt(2); + a = hex.charAt(3) + hex.charAt(3); + } else if (hex.length === 6) { + r = hex.substring(0, 2); + g = hex.substring(2, 4); + b = hex.substring(4, 6); + } else if (hex.length === 8) { + r = hex.substring(0, 2); + g = hex.substring(2, 4); + b = hex.substring(4, 6); + a = hex.substring(6, 8); + } else { + return null; + } + + // drop alpha if it's 255 (FF) + if (a === 'ff') { + return `#${r}${g}${b}`.toUpperCase(); + } + return `#${r}${g}${b}${a}`.toUpperCase(); + } + + // Handle rgb() and rgba() + if (str.startsWith('rgb')) { + const match = str.match( + /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/ + ); + if (match && match[1] && match[2] && match[3]) { + const r = Math.round(parseFloat(match[1])).toString(16).padStart(2, '0'); + const g = Math.round(parseFloat(match[2])).toString(16).padStart(2, '0'); + const b = Math.round(parseFloat(match[3])).toString(16).padStart(2, '0'); + + let a = 'ff'; + if (match[4] !== undefined) { + a = Math.round(parseFloat(match[4]) * 255) + .toString(16) + .padStart(2, '0'); + } + + if (a === 'ff') { + return `#${r}${g}${b}`.toUpperCase(); + } + return `#${r}${g}${b}${a}`.toUpperCase(); + } + } + + return null; +} diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts index be34e44f..8c88cbad 100644 --- a/src/web/normalization/htmlNormalizer.ts +++ b/src/web/normalization/htmlNormalizer.ts @@ -500,10 +500,31 @@ function walkNode(node: Node, out: { buf: string }): void { // : CSS style → inline tags if (name === 'span') { + const htmlNode = node as HTMLElement; const s = parseCssStyle(node.getAttribute('style')); + + const fg = htmlNode.style.color; + const bg = htmlNode.style.backgroundColor; + + // build the preserved span if colors exist + let spanOpen = ''; + let spanClose = ''; + const styleParts: string[] = []; + + if (fg) styleParts.push(`color: ${fg}`); + if (bg) styleParts.push(`background-color: ${bg}`); + + if (styleParts.length > 0) { + spanOpen = ``; + spanClose = ''; + } + + // output everything in the correct nested order + out.buf += spanOpen; out.buf += emitStylesOpen(s); walkChildren(node, out); out.buf += emitStylesClose(s); + out.buf += spanClose; return; } diff --git a/src/web/normalization/tiptapHtmlNormalizer.ts b/src/web/normalization/tiptapHtmlNormalizer.ts index 9ebd2c65..1af39be3 100644 --- a/src/web/normalization/tiptapHtmlNormalizer.ts +++ b/src/web/normalization/tiptapHtmlNormalizer.ts @@ -2,6 +2,7 @@ import { checkboxHtmlForTiptap, checkboxHtmlFromTiptap, } from './checkboxHtmlNormalizer'; +import { normalizeColorToHex } from './colorNormalizer'; import { normalizeHtml } from './htmlNormalizer'; export function prepareHtmlForTiptap( @@ -36,5 +37,30 @@ export function normalizeHtmlFromTiptap(html: string): string { return ``; }); + // Find all style="..." attributes in the HTML + html = html.replace(/style="([^"]*)"/gi, (_, styleString: string) => { + let updatedStyle = styleString; + + // Convert color: to hex + updatedStyle = updatedStyle.replace( + /(?:^|;)\s*color\s*:\s*([^;]+)/gi, + (match, colorValue) => { + const hex = normalizeColorToHex(colorValue); + return hex ? match.replace(colorValue, hex) : match; + } + ); + + // Convert background-color: to hex + updatedStyle = updatedStyle.replace( + /(?:^|;)\s*background-color\s*:\s*([^;]+)/gi, + (match, bgColorValue) => { + const hex = normalizeColorToHex(bgColorValue); + return hex ? match.replace(bgColorValue, hex) : match; + } + ); + + return `style="${updatedStyle}"`; + }); + return `${html}`; } diff --git a/src/web/pmPlugins/StripMarksInCodeBlockPlugin.ts b/src/web/pmPlugins/StripMarksInCodeBlockPlugin.ts index 5eee201f..66836e1a 100644 --- a/src/web/pmPlugins/StripMarksInCodeBlockPlugin.ts +++ b/src/web/pmPlugins/StripMarksInCodeBlockPlugin.ts @@ -18,6 +18,9 @@ export const StripMarksInCodeBlockPlugin = Extension.create({ newState.doc.descendants((node, pos) => { if (node.type.name === 'codeBlock') { allMarks.forEach((markType) => { + if (markType.name === 'customStyle') { + return; + } tr.removeMark(pos + 1, pos + node.nodeSize - 1, markType); }); return false; diff --git a/src/web/useOnChangeState.ts b/src/web/useOnChangeState.ts index 6f4fcb17..08b52eb7 100644 --- a/src/web/useOnChangeState.ts +++ b/src/web/useOnChangeState.ts @@ -99,24 +99,30 @@ function buildState( }, alignment: 'left', customStyle: { - foregroundColor: '', - backgroundColor: '', + foregroundColor: + editor.getAttributes('customStyle').foregroundColor ?? '', + backgroundColor: + editor.getAttributes('customStyle').backgroundColor ?? '', }, }; } function hashState(state: OnChangeStateEvent): string { - return Object.values(state) - .map((formatState) => - String( - getFormatHash( - formatState.isActive, - formatState.isConflicting, - formatState.isBlocking - ) - ) - ) + const formatEntries = Object.entries(state).filter( + ([key]) => key !== 'alignment' && key !== 'customStyle' + ); + const formatHash = formatEntries + .map(([, formatState]) => { + const s = formatState as { + isActive: boolean; + isConflicting: boolean; + isBlocking: boolean; + }; + return String(getFormatHash(s.isActive, s.isConflicting, s.isBlocking)); + }) .join(''); + + return `${formatHash}|${state.alignment}|${state.customStyle.foregroundColor}|${state.customStyle.backgroundColor}`; } function getFormatHash(