diff --git a/packages/roosterjs-editor-core/lib/coreApi/transformColor.ts b/packages/roosterjs-editor-core/lib/coreApi/transformColor.ts index 2353f5a635f..96bf5fc8575 100644 --- a/packages/roosterjs-editor-core/lib/coreApi/transformColor.ts +++ b/packages/roosterjs-editor-core/lib/coreApi/transformColor.ts @@ -1,23 +1,6 @@ import { ColorTransformDirection, EditorCore, TransformColor } from 'roosterjs-editor-types'; -import { setColor } from 'roosterjs-editor-dom'; import type { CompatibleColorTransformDirection } from 'roosterjs-editor-types/lib/compatibleTypes'; -const enum ColorAttributeEnum { - CssColor = 0, - HtmlColor = 1, -} - -const ColorAttributeName: { [key in ColorAttributeEnum]: string }[] = [ - { - [ColorAttributeEnum.CssColor]: 'color', - [ColorAttributeEnum.HtmlColor]: 'color', - }, - { - [ColorAttributeEnum.CssColor]: 'background-color', - [ColorAttributeEnum.HtmlColor]: 'bgcolor', - }, -]; - /** * @internal * Edit and transform color of elements between light mode and dark mode @@ -36,37 +19,21 @@ export const transformColor: TransformColor = ( callback: (() => void) | null, direction: ColorTransformDirection | CompatibleColorTransformDirection, forceTransform?: boolean, - fromDarkMode?: boolean + fromDarkMode: boolean = false ) => { - const { darkColorHandler } = core; - const toDark = direction == ColorTransformDirection.LightToDark; - + const { + darkColorHandler, + lifecycle: { onExternalContentTransform }, + } = core; + const toDarkMode = direction == ColorTransformDirection.LightToDark; if (rootNode && (forceTransform || core.lifecycle.isDarkMode)) { - const transformer = - core.lifecycle.onExternalContentTransform || - ((element: HTMLElement) => { - ColorAttributeName.forEach((names, i) => { - const color = darkColorHandler.parseColorValue( - element.style.getPropertyValue(names[ColorAttributeEnum.CssColor]) || - element.getAttribute(names[ColorAttributeEnum.HtmlColor]), - !!fromDarkMode - ).lightModeColor; - - element.style.setProperty(names[ColorAttributeEnum.CssColor], null); - element.removeAttribute(names[ColorAttributeEnum.HtmlColor]); - - if (color && color != 'inherit') { - setColor( - element, - color, - i != 0, - toDark, - false /*shouldAdaptFontColor*/, - darkColorHandler - ); - } - }); - }); + const transformer = onExternalContentTransform + ? (element: HTMLElement) => { + onExternalContentTransform(element, fromDarkMode, toDarkMode, darkColorHandler); + } + : (element: HTMLElement) => { + darkColorHandler.transformElementColor(element, fromDarkMode, toDarkMode); + }; iterateElements(rootNode, transformer, includeSelf); } diff --git a/packages/roosterjs-editor-core/lib/editor/DarkColorHandlerImpl.ts b/packages/roosterjs-editor-core/lib/editor/DarkColorHandlerImpl.ts index 704d2bbc04b..e10f1b5c17b 100644 --- a/packages/roosterjs-editor-core/lib/editor/DarkColorHandlerImpl.ts +++ b/packages/roosterjs-editor-core/lib/editor/DarkColorHandlerImpl.ts @@ -1,9 +1,23 @@ import { ColorKeyAndValue, DarkColorHandler, ModeIndependentColor } from 'roosterjs-editor-types'; -import { getObjectKeys, parseColor } from 'roosterjs-editor-dom'; +import { getObjectKeys, parseColor, setColor } from 'roosterjs-editor-dom'; const VARIABLE_REGEX = /^\s*var\(\s*(\-\-[a-zA-Z0-9\-_]+)\s*(?:,\s*(.*))?\)\s*$/; const VARIABLE_PREFIX = 'var('; const COLOR_VAR_PREFIX = 'darkColor'; +const enum ColorAttributeEnum { + CssColor = 0, + HtmlColor = 1, +} +const ColorAttributeName: { [key in ColorAttributeEnum]: string }[] = [ + { + [ColorAttributeEnum.CssColor]: 'color', + [ColorAttributeEnum.HtmlColor]: 'color', + }, + { + [ColorAttributeEnum.CssColor]: 'background-color', + [ColorAttributeEnum.HtmlColor]: 'bgcolor', + }, +]; /** * @internal @@ -129,4 +143,27 @@ export default class DarkColorHandlerImpl implements DarkColorHandler { return null; } + + /** + * Transform element color, from dark to light or from light to dark + * @param element The element to transform color + * @param fromDarkMode Whether this is transforming color from dark mode + * @param toDarkMode Whether this is transforming color to dark mode + */ + transformElementColor(element: HTMLElement, fromDarkMode: boolean, toDarkMode: boolean): void { + ColorAttributeName.forEach((names, i) => { + const color = this.parseColorValue( + element.style.getPropertyValue(names[ColorAttributeEnum.CssColor]) || + element.getAttribute(names[ColorAttributeEnum.HtmlColor]), + !!fromDarkMode + ).lightModeColor; + + element.style.setProperty(names[ColorAttributeEnum.CssColor], null); + element.removeAttribute(names[ColorAttributeEnum.HtmlColor]); + + if (color && color != 'inherit') { + setColor(element, color, i != 0, toDarkMode, false /*shouldAdaptFontColor*/, this); + } + }); + } } diff --git a/packages/roosterjs-editor-core/test/coreApi/transformColorTest.ts b/packages/roosterjs-editor-core/test/coreApi/transformColorTest.ts index 6a7b0db98f8..5efa0c47c94 100644 --- a/packages/roosterjs-editor-core/test/coreApi/transformColorTest.ts +++ b/packages/roosterjs-editor-core/test/coreApi/transformColorTest.ts @@ -1,5 +1,6 @@ import createEditorCore from './createMockEditorCore'; -import { ColorTransformDirection, DarkColorHandler } from 'roosterjs-editor-types'; +import DarkColorHandlerImpl from '../../lib/editor/DarkColorHandlerImpl'; +import { ColorTransformDirection } from 'roosterjs-editor-types'; import { getDarkColor } from 'roosterjs-color-utils'; import { itChromeOnly } from '../TestHelper'; import { transformColor } from '../../lib/coreApi/transformColor'; @@ -23,20 +24,19 @@ describe('transform to dark mode v2', () => { expectedParseValueCalls: string[], expectedRegisterColorCalls: [string, boolean, string][] ) { + const handler = new DarkColorHandlerImpl(div, getDarkColor); const core = createEditorCore(div, { inDarkMode: false, getDarkColor, }); - const parseColorValue = jasmine - .createSpy('parseColorValue') - .and.callFake((color: string) => ({ - lightModeColor: color == 'red' ? 'blue' : color == 'green' ? 'yellow' : '', - })); - const registerColor = jasmine - .createSpy('registerColor') - .and.callFake((color: string) => color); + core.darkColorHandler = handler; - core.darkColorHandler = ({ parseColorValue, registerColor } as any) as DarkColorHandler; + const parseColorValue = spyOn(handler, 'parseColorValue').and.callFake((color: string) => ({ + lightModeColor: color == 'red' ? 'blue' : color == 'green' ? 'yellow' : '', + })); + const registerColor = spyOn(handler, 'registerColor').and.callFake( + (color: string) => color + ); transformColor(core, element, true, null, ColorTransformDirection.LightToDark, true); @@ -128,19 +128,18 @@ describe('transform to light mode v2', () => { expectedParseValueCalls: string[], expectedRegisterColorCalls: [string, boolean, string][] ) { + const handler = new DarkColorHandlerImpl(div, getDarkColor); const core = createEditorCore(div, { getDarkColor, }); - const parseColorValue = jasmine - .createSpy('parseColorValue') - .and.callFake((color: string) => ({ - lightModeColor: color == 'red' ? 'blue' : color == 'green' ? 'yellow' : '', - })); - const registerColor = jasmine - .createSpy('registerColor') - .and.callFake((color: string) => color); - - core.darkColorHandler = ({ parseColorValue, registerColor } as any) as DarkColorHandler; + const parseColorValue = spyOn(handler, 'parseColorValue').and.callFake((color: string) => ({ + lightModeColor: color == 'red' ? 'blue' : color == 'green' ? 'yellow' : '', + })); + const registerColor = spyOn(handler, 'registerColor').and.callFake( + (color: string) => color + ); + + core.darkColorHandler = handler; transformColor( core, diff --git a/packages/roosterjs-editor-core/test/editor/DarkColorHandlerImplTest.ts b/packages/roosterjs-editor-core/test/editor/DarkColorHandlerImplTest.ts index d74087234df..5675abe7621 100644 --- a/packages/roosterjs-editor-core/test/editor/DarkColorHandlerImplTest.ts +++ b/packages/roosterjs-editor-core/test/editor/DarkColorHandlerImplTest.ts @@ -361,3 +361,165 @@ describe('DarkColorHandlerImpl.findLightColorFromDarkColor', () => { expect(result).toEqual('aa'); }); }); + +describe('DarkColorHandlerImpl.transformElementColor', () => { + let parseColorSpy: jasmine.Spy; + let registerColorSpy: jasmine.Spy; + let handler: DarkColorHandlerImpl; + let contentDiv: HTMLDivElement; + + beforeEach(() => { + const getDarkColor = (color: string) => 'mocked_' + color; + contentDiv = document.createElement('div'); + handler = new DarkColorHandlerImpl(contentDiv, getDarkColor); + + parseColorSpy = spyOn(handler, 'parseColorValue').and.callThrough(); + registerColorSpy = spyOn(handler, 'registerColor').and.callThrough(); + }); + + it('No color, light to dark', () => { + const span = document.createElement('span'); + handler.transformElementColor(span, false, true); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(2); + expect(parseColorSpy).toHaveBeenCalledWith(null, false); + expect(registerColorSpy).not.toHaveBeenCalled(); + }); + + it('Has simple color in HTML, light to dark', () => { + const span = document.createElement('span'); + + span.setAttribute('color', 'red'); + + handler.transformElementColor(span, false, true); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(3); + expect(parseColorSpy).toHaveBeenCalledWith('red', false); + expect(parseColorSpy).toHaveBeenCalledWith(null, false); + expect(registerColorSpy).toHaveBeenCalledTimes(1); + expect(registerColorSpy).toHaveBeenCalledWith('red', true, undefined); + }); + + it('Has simple color in CSS, light to dark', () => { + const span = document.createElement('span'); + + span.style.color = 'red'; + + handler.transformElementColor(span, false, true); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(3); + expect(parseColorSpy).toHaveBeenCalledWith('red', false); + expect(parseColorSpy).toHaveBeenCalledWith(null, false); + expect(registerColorSpy).toHaveBeenCalledTimes(1); + expect(registerColorSpy).toHaveBeenCalledWith('red', true, undefined); + }); + + it('Has color in both text and background, light to dark', () => { + const span = document.createElement('span'); + + span.style.color = 'red'; + span.style.backgroundColor = 'green'; + + handler.transformElementColor(span, false, true); + + expect(span.outerHTML).toBe( + '' + ); + expect(parseColorSpy).toHaveBeenCalledTimes(4); + expect(parseColorSpy).toHaveBeenCalledWith('red', false); + expect(parseColorSpy).toHaveBeenCalledWith('green', false); + expect(parseColorSpy).toHaveBeenCalledWith('red'); + expect(parseColorSpy).toHaveBeenCalledWith('green'); + expect(registerColorSpy).toHaveBeenCalledTimes(2); + expect(registerColorSpy).toHaveBeenCalledWith('red', true, undefined); + expect(registerColorSpy).toHaveBeenCalledWith('green', true, undefined); + }); + + it('Has var-based color, light to dark', () => { + const span = document.createElement('span'); + + span.style.color = 'var(--darkColor_red, red)'; + + handler.transformElementColor(span, false, true); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(3); + expect(parseColorSpy).toHaveBeenCalledWith('var(--darkColor_red, red)', false); + expect(parseColorSpy).toHaveBeenCalledWith('red'); + expect(parseColorSpy).toHaveBeenCalledWith(null, false); + expect(registerColorSpy).toHaveBeenCalledTimes(1); + expect(registerColorSpy).toHaveBeenCalledWith('red', true, undefined); + }); + + it('No color, dark to light', () => { + const span = document.createElement('span'); + handler.transformElementColor(span, true, false); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(2); + expect(parseColorSpy).toHaveBeenCalledWith(null, true); + expect(registerColorSpy).not.toHaveBeenCalled(); + }); + + it('Has simple color in HTML, dark to light', () => { + const span = document.createElement('span'); + + span.setAttribute('color', 'red'); + + handler.transformElementColor(span, true, false); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(2); + expect(parseColorSpy).toHaveBeenCalledWith('red', true); + expect(parseColorSpy).toHaveBeenCalledWith(null, true); + expect(registerColorSpy).not.toHaveBeenCalled(); + }); + + it('Has simple color in CSS, dark to light', () => { + const span = document.createElement('span'); + + span.style.color = 'red'; + + handler.transformElementColor(span, true, false); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(2); + expect(parseColorSpy).toHaveBeenCalledWith('red', true); + expect(parseColorSpy).toHaveBeenCalledWith(null, true); + expect(registerColorSpy).not.toHaveBeenCalled(); + }); + + it('Has color in both text and background, dark to light', () => { + const span = document.createElement('span'); + + span.style.color = 'red'; + span.style.backgroundColor = 'green'; + + handler.transformElementColor(span, true, false); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(2); + expect(parseColorSpy).toHaveBeenCalledWith('red', true); + expect(parseColorSpy).toHaveBeenCalledWith('green', true); + expect(registerColorSpy).not.toHaveBeenCalled(); + }); + + it('Has var-based color, dark to light', () => { + const span = document.createElement('span'); + + span.style.color = 'var(--darkColor_red, red)'; + + handler.transformElementColor(span, true, false); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(3); + expect(parseColorSpy).toHaveBeenCalledWith('var(--darkColor_red, red)', true); + expect(parseColorSpy).toHaveBeenCalledWith('red'); + expect(parseColorSpy).toHaveBeenCalledWith(null, true); + expect(registerColorSpy).toHaveBeenCalledTimes(1); + expect(registerColorSpy).toHaveBeenCalledWith('red', false, undefined); + }); +}); diff --git a/packages/roosterjs-editor-types/lib/corePluginState/LifecyclePluginState.ts b/packages/roosterjs-editor-types/lib/corePluginState/LifecyclePluginState.ts index b5e3da07936..0f60fd7e0da 100644 --- a/packages/roosterjs-editor-types/lib/corePluginState/LifecyclePluginState.ts +++ b/packages/roosterjs-editor-types/lib/corePluginState/LifecyclePluginState.ts @@ -1,4 +1,5 @@ import CustomData from '../interface/CustomData'; +import DarkColorHandler from '../interface/DarkColorHandler'; import DefaultFormat from '../interface/DefaultFormat'; import SelectionPath from '../interface/SelectionPath'; import { ExperimentalFeatures } from '../enum/ExperimentalFeatures'; @@ -31,7 +32,14 @@ export default interface LifecyclePluginState { /** * External content transform function to help do color transform for existing content */ - onExternalContentTransform: ((htmlIn: HTMLElement) => void) | null; + onExternalContentTransform: + | (( + element: HTMLElement, + fromDarkMode: boolean, + toDarkMode: boolean, + darkColorHandler: DarkColorHandler + ) => void) + | null; /** * Enabled experimental features diff --git a/packages/roosterjs-editor-types/lib/interface/DarkColorHandler.ts b/packages/roosterjs-editor-types/lib/interface/DarkColorHandler.ts index e8b158593a9..eeb600ce45d 100644 --- a/packages/roosterjs-editor-types/lib/interface/DarkColorHandler.ts +++ b/packages/roosterjs-editor-types/lib/interface/DarkColorHandler.ts @@ -57,4 +57,12 @@ export default interface DarkColorHandler { * @param darkColor The existing dark color */ findLightColorFromDarkColor(darkColor: string): string | null; + + /** + * Transform element color, from dark to light or from light to dark + * @param element The element to transform color + * @param fromDarkMode Whether this is transforming color from dark mode + * @param toDarkMode Whether this is transforming color to dark mode + */ + transformElementColor(element: HTMLElement, fromDarkMode: boolean, toDarkMode: boolean): void; } diff --git a/packages/roosterjs-editor-types/lib/interface/EditorOptions.ts b/packages/roosterjs-editor-types/lib/interface/EditorOptions.ts index 987af512318..5a48b426145 100644 --- a/packages/roosterjs-editor-types/lib/interface/EditorOptions.ts +++ b/packages/roosterjs-editor-types/lib/interface/EditorOptions.ts @@ -1,4 +1,5 @@ import CorePlugins from './CorePlugins'; +import DarkColorHandler from './DarkColorHandler'; import DefaultFormat from './DefaultFormat'; import EditorPlugin from './EditorPlugin'; import Rect from './Rect'; @@ -72,7 +73,12 @@ export default interface EditorOptions { * If you want to change this behavior, you may define a different function here. * It takes in the impacted HTMLElement */ - onExternalContentTransform?: (htmlIn: HTMLElement) => void; + onExternalContentTransform?: ( + element: HTMLElement, + fromDarkMode: boolean, + toDarkMode: boolean, + darkColorHandler: DarkColorHandler + ) => void; /** * A util function to transform light mode color to dark mode color