Skip to content

Commit

Permalink
Merge pull request #20 from ckeditor/ck/css-injector-order
Browse files Browse the repository at this point in the history
Feature: CSS scripts are now injected at the beginning of the document head, allowing for easier overriding of editor styles.
  • Loading branch information
Mati365 authored Aug 29, 2024
2 parents ce95be6 + 49306f8 commit cd61e7c
Show file tree
Hide file tree
Showing 4 changed files with 147 additions and 11 deletions.
7 changes: 5 additions & 2 deletions src/cdn/loadCKCdnResourcesPack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,12 @@ export async function loadCKCdnResourcesPack<P extends CKCdnResourcesPack<any>>(
// Preload resources specified in the pack.
preload.forEach( preloadResource );

// Load stylesheet tags before scripts to avoid flash of unstyled content.
// Load stylesheet tags before scripts to avoid a flash of unstyled content.
await Promise.all(
uniq( stylesheets ).map( injectStylesheet )
uniq( stylesheets ).map( href => injectStylesheet( {
href,
placementInHead: 'start'
} ) )
);

// Load script tags.
Expand Down
48 changes: 45 additions & 3 deletions src/utils/injectStylesheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ export const INJECTED_STYLESHEETS = new Map<string, Promise<void>>();
/**
* Injects a stylesheet into the document.
*
* @param href The URL of the stylesheet to be injected.
* @param props.href The URL of the stylesheet to be injected.
* @param props.placementInHead The placement of the stylesheet in the head.
* @returns A promise that resolves when the stylesheet is loaded.
*/
export function injectStylesheet( href: string ): Promise<void> {
export function injectStylesheet( { href, placementInHead = 'start' }: InjectStylesheetProps ): Promise<void> {
// Return the promise if the stylesheet is already injected by this function.
if ( INJECTED_STYLESHEETS.has( href ) ) {
return INJECTED_STYLESHEETS.get( href )!;
Expand All @@ -30,6 +31,30 @@ export function injectStylesheet( href: string ): Promise<void> {
maybePrevStylesheet.remove();
}

// Append the link tag to the head.
const appendLinkTagToHead = ( link: HTMLLinkElement ) => {
const previouslyInjectedStylesheets = Array.from(
document.head.querySelectorAll( 'link[data-injected-by="ckeditor-integration"][rel="stylesheet"]' )
);

switch ( placementInHead ) {
// It'll append styles *before* the stylesheets that are already present in the head
// but after the ones that are injected by this function.
case 'start':
if ( previouslyInjectedStylesheets.length ) {
previouslyInjectedStylesheets.slice( -1 )[ 0 ].after( link );
} else {
document.head.insertBefore( link, document.head.firstChild );
}
break;

// It'll append styles *after* the stylesheets already in the head.
case 'end':
document.head.appendChild( link );
break;
}
};

// Inject the stylesheet and return the promise.
const promise = new Promise<void>( ( resolve, reject ) => {
const link = document.createElement( 'link' );
Expand All @@ -43,7 +68,7 @@ export function injectStylesheet( href: string ): Promise<void> {
resolve();
};

document.head.appendChild( link );
appendLinkTagToHead( link );

// It should remove stylesheet if stylesheet is being removed from the DOM.
const observer = new MutationObserver( mutations => {
Expand All @@ -65,3 +90,20 @@ export function injectStylesheet( href: string ): Promise<void> {

return promise;
}

/**
* Props for the `injectStylesheet` function.
*/
type InjectStylesheetProps = {

/**
* The URL of the stylesheet to be injected.
*/
href: string;

/**
* The placement of the stylesheet in the head. It can be either at the start or at the end
* of the head. Default is 'start' because it allows user to override the styles easily.
*/
placementInHead?: 'start' | 'end';
};
24 changes: 24 additions & 0 deletions tests/cdn/loadCKCdnResourcesPack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { describe, it, vi, expect, vitest, beforeEach, afterEach } from 'vitest';

import { loadCKCdnResourcesPack } from '@/cdn/loadCKCdnResourcesPack';
import { createCKCdnUrl } from '@/src/cdn/ck/createCKCdnUrl';
import {
queryScript,
queryStylesheet,
Expand Down Expand Up @@ -131,4 +132,27 @@ describe( 'loadCKCdnResourcesPack', () => {

expect( customFunction ).toHaveBeenCalled();
} );

it( 'should inject the stylesheet at the start of the head', async () => {
// Manually inject the stylesheet into the document.
const stylesheet1 = document.createElement( 'link' );
stylesheet1.rel = 'stylesheet';
stylesheet1.href = createCKCdnUrl( 'ckeditor5', 'ckeditor5.css', '42.0.0' );
document.head.appendChild( stylesheet1 );

// Inject the stylesheet using the loadCKCdnResourcesPack function.
await loadCKCdnResourcesPack( {
stylesheets: [ CDN_MOCK_STYLESHEET_URL ]
} );

// Verify that the stylesheet are injected at the start of the head.
const injectedStylesheets = [ ...document.head.querySelectorAll( 'link[rel="stylesheet"]' ) ].map(
link => link.getAttribute( 'href' )!
);

expect( injectedStylesheets ).toEqual( [
CDN_MOCK_STYLESHEET_URL,
createCKCdnUrl( 'ckeditor5', 'ckeditor5.css', '42.0.0' )
] );
} );
} );
79 changes: 73 additions & 6 deletions tests/utils/injectStylesheet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { CDN_MOCK_STYLESHEET_URL, removeCkCdnLinks } from 'tests/_utils/ckCdnMocks';
import { injectStylesheet } from '@/utils/injectStylesheet';
import { createCKCdnUrl } from '@/src/cdn/ck/createCKCdnUrl';

describe( 'injectStylesheet', () => {
beforeEach( () => {
Expand All @@ -23,23 +24,27 @@ describe( 'injectStylesheet', () => {
it( 'should inject a stylesheet into the document', async () => {
// Mock the document and stylesheet element
const createElementSpy = vi.spyOn( document, 'createElement' );
const appendChildSpy = vi.spyOn( document.head, 'appendChild' );
const insertBeforeSpy = vi.spyOn( document.head, 'insertBefore' );

// Call the injectStylesheet function
const promise = injectStylesheet( CDN_MOCK_STYLESHEET_URL );
const firstHeadChild = document.head.firstChild;
const promise = injectStylesheet( { href: CDN_MOCK_STYLESHEET_URL } );

// Verify that the stylesheet element is created and appended to the document
expect( createElementSpy ).toHaveBeenCalledWith( 'link' );
expect( appendChildSpy ).toHaveBeenCalledWith( expect.any( HTMLLinkElement ) );
expect( insertBeforeSpy ).toHaveBeenCalledWith(
expect.any( HTMLLinkElement ),
firstHeadChild
);

// Wait for the promise to resolve
await expect( promise ).resolves.toBeUndefined();
} );

it( 'should return the same promise if the stylesheet is already injected', async () => {
// Call the injectStylesheet function twice with the same source
const promise1 = injectStylesheet( CDN_MOCK_STYLESHEET_URL );
const promise2 = injectStylesheet( CDN_MOCK_STYLESHEET_URL );
const promise1 = injectStylesheet( { href: CDN_MOCK_STYLESHEET_URL } );
const promise2 = injectStylesheet( { href: CDN_MOCK_STYLESHEET_URL } );

// Verify that the promises are the same
expect( promise1 ).toBe( promise2 );
Expand All @@ -58,7 +63,7 @@ describe( 'injectStylesheet', () => {
document.head.appendChild( stylesheet );

// Call the injectStylesheet function
const promise = injectStylesheet( CDN_MOCK_STYLESHEET_URL );
const promise = injectStylesheet( { href: CDN_MOCK_STYLESHEET_URL } );

// Wait for the promise to resolve
await expect( promise ).resolves.toBeUndefined();
Expand All @@ -68,4 +73,66 @@ describe( 'injectStylesheet', () => {
`Stylesheet with "${ CDN_MOCK_STYLESHEET_URL }" href is already present in DOM!`
);
} );

it( 'should inject the stylesheet at the end of the head if headPlacement = \'end\'', async () => {
// Mock the document and stylesheet element
const createElementSpy = vi.spyOn( document, 'createElement' );
const appendChildSpy = vi.spyOn( document.head, 'appendChild' );

// Call the injectStylesheet function with headPlacement = 'end'
const promise = injectStylesheet( { href: CDN_MOCK_STYLESHEET_URL, placementInHead: 'end' } );

// Verify that the stylesheet element is created and appended to the document
expect( createElementSpy ).toHaveBeenCalledWith( 'link' );
expect( appendChildSpy ).toHaveBeenCalled();

// Wait for the promise to resolve
await expect( promise ).resolves.toBeUndefined();
} );

it( 'should inject the stylesheet at the start of the head if headPlacement = \'start\'', async () => {
// Mock the document and stylesheet element
const createElementSpy = vi.spyOn( document, 'createElement' );
const insertBeforeSpy = vi.spyOn( document.head, 'insertBefore' );

// Call the injectStylesheet function with headPlacement = 'start'
const promise = injectStylesheet( { href: CDN_MOCK_STYLESHEET_URL, placementInHead: 'start' } );

// Verify that the stylesheet element is created and appended to the document
expect( createElementSpy ).toHaveBeenCalledWith( 'link' );
expect( insertBeforeSpy ).toHaveBeenCalled();

// Wait for the promise to resolve
await expect( promise ).resolves.toBeUndefined();
} );

it( 'should inject the stylesheet after previously injected stylesheets if headPlacement = \'start\'', async () => {
// Manually inject the stylesheet into the document.
const stylesheet1 = document.createElement( 'link' );
stylesheet1.rel = 'stylesheet';
stylesheet1.href = createCKCdnUrl( 'ckeditor5', 'ckeditor5.css', '42.0.0' );
document.head.appendChild( stylesheet1 );

// Call the injectStylesheet function with headPlacement = 'start'
await injectStylesheet( {
href: CDN_MOCK_STYLESHEET_URL,
placementInHead: 'start'
} );

await injectStylesheet( {
href: createCKCdnUrl( 'ckeditor5', 'ckeditor5.css', '42.0.1' ),
placementInHead: 'start'
} );

// Verify that the stylesheet is injected after the previously injected stylesheet
const injectedStylesheets = [ ...document.head.querySelectorAll( 'link[rel="stylesheet"]' ) ].map(
link => link.getAttribute( 'href' )!
);

expect( injectedStylesheets ).toEqual( [
CDN_MOCK_STYLESHEET_URL,
createCKCdnUrl( 'ckeditor5', 'ckeditor5.css', '42.0.1' ),
createCKCdnUrl( 'ckeditor5', 'ckeditor5.css', '42.0.0' )
] );
} );
} );

0 comments on commit cd61e7c

Please sign in to comment.