Skip to content

Instantly share code, notes, and snippets.

@NeuroNexul
Last active June 25, 2023 14:17
Show Gist options
  • Save NeuroNexul/2c35d6ecf11d8b7fdd68495e3282f4b7 to your computer and use it in GitHub Desktop.
Save NeuroNexul/2c35d6ecf11d8b7fdd68495e3282f4b7 to your computer and use it in GitHub Desktop.
Integrating CKEditor5 in Next JS: A How-To Guide
:root {
/* Overrides the border radius setting in the theme. */
--ck-border-radius: 4px !important;
/* Overrides the default font size in the theme. */
--ck-font-size-base: 1rem !important;
/* Helper variables to avoid duplication in the colors. */
--ck-custom-background: hsl(270, 1%, 29%) !important;
--ck-custom-foreground: hsl(255, 3%, 18%) !important;
--ck-custom-border: hsl(300, 1%, 22%) !important;
--ck-custom-white: hsl(0, 0%, 100%) !important;
/* -- Overrides generic colors. ------------------------------------------------------------- */
--ck-color-base-foreground: var(--ck-custom-background) !important;
--ck-color-focus-border: hsl(208, 90%, 62%) !important;
--ck-color-text: hsl(0, 0%, 98%) !important;
--ck-color-shadow-drop: hsla(0, 0%, 0%, 0.2) !important;
--ck-color-shadow-inner: hsla(0, 0%, 0%, 0.1) !important;
/* -- Overrides the default .ck-button class colors. ---------------------------------------- */
--ck-color-button-default-background: var(--ck-custom-background) !important;
--ck-color-button-default-hover-background: hsl(270, 1%, 22%) !important;
--ck-color-button-default-active-background: hsl(270, 2%, 20%) !important;
--ck-color-button-default-active-shadow: hsl(270, 2%, 23%) !important;
--ck-color-button-default-disabled-background: var(--ck-custom-background) !important;
--ck-color-button-on-background: var(--ck-custom-foreground) !important;
--ck-color-button-on-hover-background: hsl(255, 4%, 16%) !important;
--ck-color-button-on-active-background: hsl(255, 4%, 14%) !important;
--ck-color-button-on-active-shadow: hsl(240, 3%, 19%) !important;
--ck-color-button-on-disabled-background: var(--ck-custom-foreground) !important;
--ck-color-button-action-background: hsl(168, 76%, 42%) !important;
--ck-color-button-action-hover-background: hsl(168, 76%, 38%) !important;
--ck-color-button-action-active-background: hsl(168, 76%, 36%) !important;
--ck-color-button-action-active-shadow: hsl(168, 75%, 34%) !important;
--ck-color-button-action-disabled-background: hsl(168, 76%, 42%) !important;
--ck-color-button-action-text: var(--ck-custom-white) !important;
--ck-color-button-save: hsl(120, 100%, 46%) !important;
--ck-color-button-cancel: hsl(15, 100%, 56%) !important;
/* -- Overrides the default .ck-dropdown class colors. -------------------------------------- */
--ck-color-dropdown-panel-background: var(--ck-custom-background) !important;
--ck-color-dropdown-panel-border: var(--ck-custom-foreground) !important;
/* -- Overrides the default .ck-splitbutton class colors. ----------------------------------- */
--ck-color-split-button-hover-background: var(--ck-color-button-default-hover-background) !important;
--ck-color-split-button-hover-border: var(--ck-custom-foreground) !important;
/* -- Overrides the default .ck-input class colors. ----------------------------------------- */
--ck-color-input-background: var(--ck-custom-background) !important;
--ck-color-input-border: hsl(257, 3%, 43%) !important;
--ck-color-input-text: hsl(0, 0%, 98%) !important;
--ck-color-input-disabled-background: hsl(255, 4%, 21%) !important;
--ck-color-input-disabled-border: hsl(250, 3%, 38%) !important;
--ck-color-input-disabled-text: hsl(0, 0%, 78%) !important;
/* -- Overrides the default .ck-labeled-field-view class colors. ---------------------------- */
--ck-color-labeled-field-label-background: var(--ck-custom-background) !important;
/* -- Overrides the default .ck-list class colors. ------------------------------------------ */
--ck-color-list-background: var(--ck-custom-background) !important;
--ck-color-list-button-hover-background: var(--ck-color-base-foreground) !important;
--ck-color-list-button-on-background: var(--ck-color-base-active) !important;
--ck-color-list-button-on-background-focus: var(--ck-color-base-active-focus) !important;
--ck-color-list-button-on-text: var(--ck-color-base-background) !important;
/* -- Overrides the default .ck-balloon-panel class colors. --------------------------------- */
--ck-color-panel-background: var(--ck-custom-background) !important;
--ck-color-panel-border: var(--ck-custom-border) !important;
/* -- Overrides the default .ck-toolbar class colors. --------------------------------------- */
--ck-color-toolbar-background: var(--ck-custom-background) !important;
--ck-color-toolbar-border: var(--ck-custom-border) !important;
/* -- Overrides the default .ck-tooltip class colors. --------------------------------------- */
--ck-color-tooltip-background: hsl(252, 7%, 14%) !important;
--ck-color-tooltip-text: hsl(0, 0%, 93%) !important;
/* -- Overrides the default colors used by the ckeditor5-image package. --------------------- */
--ck-color-image-caption-background: hsl(0, 0%, 97%) !important;
--ck-color-image-caption-text: hsl(0, 0%, 20%) !important;
/* -- Overrides the default colors used by the ckeditor5-widget package. -------------------- */
--ck-color-widget-blurred-border: hsl(0, 0%, 87%) !important;
--ck-color-widget-hover-border: hsl(43, 100%, 68%) !important;
--ck-color-widget-editable-focus-background: var(--ck-custom-white) !important;
/* -- Overrides the default colors used by the ckeditor5-link package. ---------------------- */
--ck-color-link-default: hsl(190, 100%, 75%) !important;
}
.customEditor {
color-scheme: dark;
}
.ck-editor__editable {
border: none !important;
box-shadow: none !important;
border-bottom: 1px solid rgb(var(--border-primary-color)) !important;
}
.ck.ck-editor__top.ck-reset_all {
position: sticky !important;
top: 0 !important;
z-index: 1000 !important;
}
.ck-button {
cursor: pointer !important
}
.ck-button.ck-disabled {
cursor: default !important;
}
.ck.ck-button.ck-on,
a.ck.ck-button.ck-on {
color: #35ffb4 !important;
background-color: hsl(270deg 1% 22%) !important;
}
.ck.ck-list__item .ck-button:hover:not(.ck-disabled) {
background-color: var(--ck-color-button-default-hover-background) !important
}
// @ts-ignore
import DOMPurify from 'dompurify';
import { customPostItemRenderer, customUserItemRenderer, getPosts, getUsers, MentionLinksPlugin } from './mention';
var re_weburl = new RegExp(
"^" +
// protocol identifier (optional)
// short syntax // still required
"(?:(?:(?:https?|ftp):)?\\/\\/)" +
// user:pass BasicAuth (optional)
"(?:\\S+(?::\\S*)?@)?" +
"(?:" +
// IP address exclusion
// private & local networks
"(?!(?:10|127)(?:\\.\\d{1,3}){3})" +
"(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})" +
"(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})" +
// IP address dotted notation octets
// excludes loopback network 0.0.0.0
// excludes reserved space >= 224.0.0.0
// excludes network & broadcast addresses
// (first & last IP address of each class)
"(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])" +
"(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}" +
"(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))" +
"|" +
// host & domain names, may end with dot
// can be replaced by a shortest alternative
// (?![-_])(?:[-\\w\\u00a1-\\uffff]{0,63}[^-_]\\.)+
"(?:" +
"(?:" +
"[a-z0-9\\u00a1-\\uffff]" +
"[a-z0-9\\u00a1-\\uffff_-]{0,62}" +
")?" +
"[a-z0-9\\u00a1-\\uffff]\\." +
")+" +
// TLD identifier name, may end with dot
"(?:[a-z\\u00a1-\\uffff]{2,}\\.?)" +
")" +
// port number (optional)
"(?::\\d{2,5})?" +
// resource path (optional)
"(?:[/?#]\\S*)?" +
"$", "i"
);
/**
*
* @param {*} props
* @returns {import("@types/ckeditor__ckeditor5-core/src/editor/editorconfig").EditorConfig}
*/
export default function EditorToolbarConfig(props: any) {
var _code_languages = props.languages;
return {
language: 'en',
tabSpaces: 4,
extraPlugins: [
...(props.extraPlugins || []),
MentionLinksPlugin
],
toolbar: {
items: [
'heading',
'style',
'|',
'bold',
'italic',
'underline',
'strikethrough',
'link',
'blockQuote',
'subscript',
'superscript',
'removeFormat',
'|',
'fontBackgroundColor',
'fontColor',
'fontFamily',
'fontSize',
'highlight',
'|',
'alignment',
'indent',
'outdent',
'numberedList',
'bulletedList',
'todoList',
'insertTable',
'|',
'imageInsert',
'mediaEmbed',
'|',
'code',
'codeBlock',
'|',
'findAndReplace',
'horizontalLine',
'htmlEmbed',
'pageBreak',
'specialCharacters',
'restrictedEditingException',
'selectAll',
'sourceEditing',
'|',
'textPartLanguage',
'|',
'undo',
'redo'
]
},
heading: {
options: [
{ model: 'paragraph', title: 'Paragraph Text', class: 'ck-heading_paragraph' },
{ model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' },
{ model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' },
{ model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ck-heading_heading3' },
{ model: 'heading4', view: 'h4', title: 'Heading 4', class: 'ck-heading_heading4' },
{ model: 'heading5', view: 'h5', title: 'Heading 5', class: 'ck-heading_heading5' },
{ model: 'heading6', view: 'h6', title: 'Heading 6', class: 'ck-heading_heading6' },
]
},
fontFamily: {
supportAllValues: true
},
fontSize: {
// options: ['8rem', '10rem', '12rem', '14rem', 'default', '18rem', '20rem', '22rem', '30rem', '32rem'],
options: ['0.5', '0.6', '0.7', '0.8', '0.9', '1.0', '1.1', '1.2', '1.3', '1.4', '1.5', '1.6', '1.7', '1.8', '1.9', '2.0'].map(val => ({
model: val,
title: `x${val}`,
view: {
name: 'span',
styles: {
'font-size': `${val}rem`
}
}
}))
// supportAllValues: true
},
fontColor: {
colors,
columns: 10,
documentColors: 20,
},
fontBackgroundColor: {
colors,
columns: 10,
documentColors: 20,
},
image: {
toolbar: [
'imageStyle:inline',
'imageStyle:wrapText',
'imageStyle:alignCenter',
'imageStyle:breakText',
'imageStyle:side',
'imageStyle:sideLeft',
'|',
'imageResize:25',
'imageResize:50',
'imageResize:75',
'imageResize:original',
'|',
'imageTextAlternative',
'toggleImageCaption',
'linkImage'
],
resizeUnit: '%',
resizeOptions: [
{
name: 'imageResize:original',
value: null,
icon: 'original'
},
{
name: 'imageResize:25',
value: '25',
icon: 'small'
},
{
name: 'imageResize:50',
value: '50',
icon: 'medium'
},
{
name: 'imageResize:75',
value: '75',
icon: 'large'
}
],
},
link: {
toggleDownloadable: {
mode: 'manual',
label: 'Downloadable',
attributes: {
download: 'file'
}
},
decorators: {
openInNewTab: {
mode: 'manual',
label: 'Open in a new tab',
defaultValue: true, // This option will be selected by default.
attributes: {
target: '_blank',
rel: 'noopener noreferrer'
}
}
}
},
table: {
contentToolbar: [
'tableColumn',
'tableRow',
'mergeTableCells',
'tableCellProperties',
'tableProperties',
'toggleTableCaption'
]
},
mediaEmbed: {
toolbar: [
'mediaEmbed',
],
extraProviders: [
{
name: 'All',
// A URL regexp or an array of URL regexps:
url: re_weburl,
// To be defined only if the media are previewable:
html: (match: any) => {
return (
'<div style="position: relative; padding-bottom: 100%; height: 0; padding-bottom: 56.2493%;">' +
`<iframe src="${match[0]}" ` +
'style="position: absolute; width: 100%; height: 100%; top: 0; left: 0;" ' +
'frameborder="0" allow="autoplay; encrypted-media" allowfullscreen>' +
'</iframe>' +
'</div>'
);
}
},
],
previewsInData: true
},
codeBlock: {
languages: _code_languages.map((_language: any) => {
return {
language: _language,
label: _language === "cs" ? "c#" : _language.toUpperCase()
};
}),
},
mention: {
feeds: [
...props.mentionFeeds || [],
{
marker: '@',
feed: getUsers,
itemRenderer: customUserItemRenderer
},
{
marker: '#',
feed: getPosts,
itemRenderer: customPostItemRenderer
},
],
},
htmlEmbed: {
showPreviews: true,
sanitizeHtml: (inputHtml: any) => {
// Strip unsafe elements and attributes, e.g.:
// the `<script>` elements and `on*` attributes.
const purify = DOMPurify(window);
const outputHtml = purify.sanitize(inputHtml, {
ADD_TAGS: ["NOTE"]
});
// const outputHtml = sanitizeHtml(inputHtml);
return {
html: outputHtml,
// true or false depending on whether the sanitizer stripped anything.
hasChanged: true
};
}
},
style: {
definitions: [
{
name: 'Info tag',
element: 'div',
classes: ['info-tag']
}
],
},
}
};
const colors = [
// Black
...colorRow(0, 0, 10, true),
...colorRow(0, 100, 10),
...colorRow(30, 100, 10),
...colorRow(60, 100, 10),
...colorRow(90, 100, 10),
...colorRow(120, 100, 10),
...colorRow(150, 100, 10),
...colorRow(180, 100, 10),
...colorRow(210, 100, 10),
...colorRow(240, 100, 10),
...colorRow(270, 100, 10),
...colorRow(300, 100, 10),
...colorRow(330, 100, 10),
...colorRow(360, 100, 10),
];
function colorRow(h: number, s: number, row: number, isBlack?: boolean) {
const palette = [];
for (let i = 0; i < row; i++) {
let index = isBlack ? i : (i + 1);
let r = isBlack ? (row - 1) : (row + 1);
palette.push({
color: `hsl(${h}, ${s}%, ${100 / r * index}%)`,
label: `hsl(${h}, ${s}%, ${100 / r * index}%)`,
hasBorder: true
});
}
return palette;
}
'use client'; // Necessary for next js to handle this component in client side only.
import React from 'react'
import { CKEditor } from '@ckeditor/ckeditor5-react';
// @ts-ignore
import CustomEditor from 'ckeditor5-custom-build/build/ckeditor';
import EditorConfig from '@/components/editor/editor/utils/editor-toolbar.config';
import prismComponents from "prismjs/components";
import axios from 'axios';
// Collect all languages from prismjs
const languages = ["plane", ...Object.keys(prismComponents.languages).filter(e => ![
"meta",
"django"
].includes(e)).sort()];
type Props = {
data: string;
}
/**
* Create Custom Editor Component.
*/
export default function Editor(props: Props) {
// Get editor config from editor-toolbar.config.ts
const editorConfig = EditorConfig({ ...props, languages: (languages) });
const [isEditorReady, setIsEditorReady] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const [data, setData] = React.useState(props.data);
async function Save() {
// handle data
}
return (
<div className={`relative z-50`}>
<div className={`w-full p-2`}>
<button
onClick={Save}
className={`bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded disabled:opacity-70`}
disabled={isSaving}>
{isSaving ? "Saving..." : "Save"}
</button>
</div>
<CKEditor
editor={CustomEditor}
data={props.data}
config={editorConfig}
id={"editor"}
onReady={() => setIsEditorReady(true)}
onChange={(event, editor) => {
const data = editor.getData();
setData(data);
}}
/>
</div>
)
}
import axios from "axios";
export function getUsers(query: any) {
return new Promise(resolve => {
axios.post("/api/global/find", { query, usersOnly: true }).then(res => {
const newData = res.data.users.map((user: any) => {
return {
...user,
id: `@${user.name}`,
link: `/u/${user._id}`,
}
});
resolve(newData);
});
});
}
/*
* This plugin customizes the way mentions are handled in the editor model and data.
* Instead of a classic <span class="mention"></span>,
*/
export function MentionLinksPlugin(editor: any) {
editor.conversion.for('upcast').elementToAttribute({
view: {
name: 'a',
key: 'data-mention',
classes: 'mention',
attributes: {
href: true,
// For user
'data-user-id': true,
'data-user-name': true,
'data-user-email': true,
'data-user-image': true,
'data-user-bio': true,
// For posts
'data-post-id': true,
'data-post-title': true,
'data-post-subTitle': true,
'data-post-banner': true,
}
},
model: {
key: 'mention',
value: (viewItem: any) => {
const mentionAttribute = editor.plugins.get('Mention').toMentionAttribute(viewItem, {
// Add any other properties that you need.
link: viewItem.getAttribute('href'),
// For user
userId: viewItem.getAttribute('data-user-id'),
name: viewItem.getAttribute('data-user-name'),
email: viewItem.getAttribute('data-user-email'),
image: viewItem.getAttribute('data-user-image'),
bio: viewItem.getAttribute('data-user-bio'),
// For posts
postId: viewItem.getAttribute('data-post-id'),
title: viewItem.getAttribute('data-post-title'),
subTitle: viewItem.getAttribute('data-post-subTitle'),
banner: viewItem.getAttribute('data-post-banner'),
});
return mentionAttribute;
}
},
converterPriority: 'high'
});
editor.conversion.for('downcast').attributeToElement({
model: 'mention',
view: (modelAttributeValue: any, { writer }: { writer: any }) => {
// Do not convert empty attributes (lack of value means no mention).
if (!modelAttributeValue) {
return;
}
return writer.createAttributeElement('a', {
class: 'mention',
'data-user-id': modelAttributeValue.id,
'data-mention': modelAttributeValue.email,
'data-user-name': modelAttributeValue.name,
'data-user-email': modelAttributeValue.email,
'data-user-image': modelAttributeValue.image,
'data-user-bio': modelAttributeValue.bio,
'data-post-id': modelAttributeValue.id,
'data-post-title': modelAttributeValue.title,
'data-post-subTitle': modelAttributeValue.subTitle,
'data-post-banner': modelAttributeValue.banner,
href: modelAttributeValue.link
}, {
// Make mention attribute to be wrapped by other attribute elements.
priority: 20,
// Prevent merging mentions together.
id: modelAttributeValue._id
});
},
converterPriority: 'high'
});
}
export function customUserItemRenderer(item: any) {
const parentElement = document.createElement('span');
parentElement.classList.add('custom-item');
parentElement.id = `mention-list-item-id-${item.email}`;
// Style
parentElement.style.display = 'block';
parentElement.style.padding = '5px';
parentElement.style.position = 'relative';
const avatarElement = document.createElement('img');
avatarElement.classList.add('avatar');
avatarElement.alt = item.name;
avatarElement.referrerPolicy = 'no-referrer';
// Style
avatarElement.style.width = '40px';
avatarElement.style.height = '40px';
avatarElement.style.borderRadius = '50%';
avatarElement.style.position = 'relative';
avatarElement.style.marginRight = '5px';
avatarElement.style.display = 'none';
// Manage Image
const avatarAltElement = document.createElement('div');
avatarAltElement.classList.add('avatar');
avatarAltElement.innerText = String(item.name)[0];
// Style
avatarAltElement.style.width = '40px';
avatarAltElement.style.height = '40px';
avatarAltElement.style.borderRadius = '50%';
avatarAltElement.style.position = 'relative';
avatarAltElement.style.marginRight = '5px';
avatarAltElement.style.display = 'inline-grid';
avatarAltElement.style.placeItems = 'center';
avatarAltElement.style.fontSize = '20px';
avatarAltElement.style.backgroundColor = `#${((1 << 24) * Math.random() | 0).toString(16)}`;
// Conditions
const Img = new Image();
Img.onload = (e) => {
avatarElement.src = item.image;
avatarAltElement.style.display = 'none';
avatarElement.style.display = 'inline-grid';
}
Img.onerror = (err) => {
avatarElement.style.display = 'none';
avatarAltElement.style.display = 'inline-grid';
}
Img.referrerPolicy = 'no-referrer';
Img.src = item.image;
// append
parentElement.appendChild(avatarElement);
parentElement.appendChild(avatarAltElement);
const userElement = document.createElement('span');
userElement.classList.add('custom-item-username');
// Style
userElement.style.display = 'inline-block';
// append
parentElement.appendChild(userElement);
const nameElement = document.createElement('span');
nameElement.classList.add('custom-item-name');
nameElement.innerText = item.name;
// Style
nameElement.style.fontSize = '14px';
nameElement.style.fontWeight = 'bold';
nameElement.style.lineHeight = '1.5';
nameElement.style.display = 'block';
// append
userElement.appendChild(nameElement);
const emailElement = document.createElement('span');
emailElement.classList.add('custom-item-email');
emailElement.textContent = item.email;
// Style
emailElement.style.fontSize = '12px';
emailElement.style.lineHeight = '1';
emailElement.style.display = 'block';
// append
userElement.appendChild(emailElement);
return parentElement;
}
export function getPosts(query: any) {
return new Promise(resolve => {
axios.post("/api/global/find", { query, postsOnly: true }).then(res => {
const newData = res.data.posts.map((post: any) => {
return {
...post,
id: `#${post.title}`,
link: `/post/${post.slug}`,
}
});
resolve(newData);
});
});
}
export function customPostItemRenderer(item: any) {
const parentElement = document.createElement('span');
parentElement.classList.add('custom-item');
parentElement.id = `mention-list-item-id-${item.title}`;
// Style
parentElement.style.display = 'block';
parentElement.style.padding = '5px';
parentElement.style.position = 'relative';
const bannerElement = document.createElement('img');
bannerElement.classList.add('banner');
bannerElement.alt = item.title;
bannerElement.referrerPolicy = 'no-referrer';
// Style
bannerElement.style.width = '40px';
bannerElement.style.height = '40px';
bannerElement.style.borderRadius = '5px';
bannerElement.style.position = 'relative';
bannerElement.style.marginRight = '5px';
bannerElement.style.display = 'none';
// Manage Image
const bannerAltElement = document.createElement('div');
bannerAltElement.classList.add('banner');
bannerAltElement.innerText = String(item.title)[0];
// Style
bannerAltElement.style.width = '40px';
bannerAltElement.style.height = '40px';
bannerAltElement.style.borderRadius = '5px';
bannerAltElement.style.position = 'relative';
bannerAltElement.style.marginRight = '5px';
bannerAltElement.style.display = 'inline-grid';
bannerAltElement.style.placeItems = 'center';
bannerAltElement.style.fontSize = '20px';
bannerAltElement.style.backgroundColor = `#${((1 << 24) * Math.random() | 0).toString(16)}`;
// Conditions
const Img = new Image();
Img.onload = (e) => {
bannerElement.src = item.banner;
bannerAltElement.style.display = 'none';
bannerElement.style.display = 'inline-grid';
}
Img.onerror = (err) => {
bannerElement.style.display = 'none';
bannerAltElement.style.display = 'inline-grid';
}
Img.referrerPolicy = 'no-referrer';
Img.src = item.banner;
// append
parentElement.appendChild(bannerElement);
parentElement.appendChild(bannerAltElement);
const userElement = document.createElement('span');
userElement.classList.add('custom-item-username');
// Style
userElement.style.display = 'inline-block';
// append
parentElement.appendChild(userElement);
const titleElement = document.createElement('span');
titleElement.classList.add('custom-item-title');
titleElement.innerText = item.title;
// Style
titleElement.style.fontSize = '14px';
titleElement.style.fontWeight = 'bold';
titleElement.style.lineHeight = '1.5';
titleElement.style.display = 'block';
// append
userElement.appendChild(titleElement);
const subTitle = document.createElement('span');
subTitle.classList.add('custom-item-sub-title');
subTitle.textContent = item.subTitle;
// Style
subTitle.style.fontSize = '12px';
subTitle.style.lineHeight = '1';
subTitle.style.display = 'block';
// append
userElement.appendChild(subTitle);
return parentElement;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment