Создаем свой веб-компонент WYSIWYG редактора. Часть 1
Сегодня мы напишем собственный современный веб-компонент WYSIWYG редактора, который поможет нам в дальнейших проектах с любыми формочками, где требуется редактирование текста от публикаций больших статей, до публикации простых комментариев. По максимуму реализуем возможности браузерных API и опубликуем npm пакет. Для нетерпеливых: github репозиторий а также npm package и git npm package.
Техническая основа и база редактора
В базовой функциональности редактора, важно предусмотреть фундамент для будущего развития компонента, а также реализовать работу с API основных возможностей которые дают нам браузеры, но также важно знать меру и не переусердствовать, в качестве базы мы могли бы взять некий bootstrap или tailwind для стилей, а для формочек некий react\vue чтобы не морочиться с биндингом данных, но тогда весь фундаментальный смысл расширяемости просто пропал, зато появилась необходимость поддерживать версии библиотек в node_modules, сегодняшний пост совсем не об этом, но все-таки чтобы не писать много лапши и получить код с хорошей читаемостью и оформлением, я воспользуюсь самодельной функцией el которая просто будет выполнять действия над возвращаемым Element из функции document.createElement
/**
* Short
* @param tagName element tag name
* @param params list of object params for document.createElements
* @returns
*/
export const el = (tagName:keyof HTMLElementTagNameMap|string, {classList, styles, props, attrs, options, append}:{
classList?: string[],
styles?: object,
props?: object,
attrs?: object,
options?: {
is?:string
},
append?: Element[]
} = {}):any => {
if(!tagName) {
throw new Error(`Undefined tag ${tagName}`);
}
const element = document.createElement(tagName, options);
// element.classList
if(classList) {
for (let i = 0; i < classList.length; i++) {
const styleClass = classList[i];
if(styleClass) {
element.classList.add(styleClass)
}
}
}
// element.style[prop]
if(styles) {
const stylesKeys = Object.keys(styles);
for (let i = 0; i < stylesKeys.length; i++) {
const key = stylesKeys[i];
element.style[key] = styles[key];
}
}
// element[prop]
if(props) {
const propKeys = Object.keys(props);
for (let i = 0; i < propKeys.length; i++) {
const key = propKeys[i];
element[key] = props[key];
}
}
// element.setAttribute(key,val)
if(attrs) {
const attrsKeys = Object.keys(attrs);
for (let i = 0; i < attrsKeys.length; i++) {
const key = attrsKeys[i];
if(attrs[key]) {
element.setAttribute(key, attrs[key]);
}
}
}
if(append) {
for (let i = 0; i < append.length; i++) {
const appendEl = append[i];
element.append(appendEl);
}
}
return element;
};
Функция сама по себе проста насколько это возможно и от себя ничего не добавляет, создана исключительно для удобства, вы можете найти похожие функции в Vue
по имени h
или в React
увидите похожий синтаксис в документации раздела Elements
. Данная функция родилась в процессе написания этого компонента из-за острой необходимости быстро и просто и удобно что-то делать с елементами DOM дерева
Надеюсь вас не смущает, что я привожу в пример листинги кода на TypeScript, а разговариваю про JavaScript, мой
{ "files": ["src/ts/app.ts"], "compilerOptions": { "module": "es2022", "skipLibCheck": true, "allowJs": false, "declaration": true, "outDir": "./dist/ts/", "declarationMap": true, "target": "ESNEXT", "removeComments": true, } }
Из основного на что стоит обратить внимание, я использую target "ESNEXT"
и пишу код в таком же стиле, если убрать из моих примеров аннотации типов, вы получите чистый JavaScript
, на более продвинутом уровне я пока не использую TypeScript . Кстати данная публикация тоже набрана и оформлена в редакторе, про который я сейчас рассказываю)
Базовые функции редактора
В редакторе мы будем поддерживать семантику HTML5 доступных нам тегов, а значит это то, с чего бы стоило начать — с тегов. Что мы знаем о HTML5 тегах в общих чертах?
<hr>
или <span>строка<span>
position
и display
CSS свойствамиfigure
, audio
, video
var
, b
, strong
или вообще никак не выделяется на фоне текста span
. abbr
, dfn
a
, abbr
, dfn
, time
Из этих знаний мы можем вывести условно, что у нас существуют блочные и строчные элементы с которыми мы хотим иметь 3 базовых действия в редакторе
- Вставлять тег и убирать его удалив или убрав форматирование у текста
- Оборачивать существующий текст в тег, по аналогии, как мы привыкли это видеть в текстовых редакторах
- Управлять не только текстом и тегом, но и атрибутами (иногда properties) тега, чтобы получить больший контроль над редактируемым текстом
В базе, на мой взгляд, это все, что должен уметь текстовый редактор. Дополнительные функции типа раскрашивания элементов в любые цвета, установку колонтитулов для страниц и вообще работа с текстом постранично, а также работа с таблицами, графиками, различные drag and drop элементы — все это не относится к идее текстового HTML5 WYSIWYG редактора, или относится косвенно в виде дополнительных возможностей, мы же начнем с азов и редактирования.
Реализуем вставку тегов
Для реализации вставки в тегов в редактор, необходимо рассказать редактору, о каких тегах он должен знать по умолчанию, у меня получился вот такой вот список
//All semantic html5 known editor tags
const allTags = [
{ tag: 'h1' },
{ tag: 'h2' },
{ tag: 'h3' },
{ tag: 'h4' },
{ tag: 'h5' },
{ tag: 'h6' },
{ tag: 'span' },
{ tag: 'mark' },
{ tag: 'small' },
{ tag: 'dfn' },
{ tag: 'a'},
{ tag: 'q'},
{ tag: 'b'},
{ tag: 'i'},
{ tag: 'u'},
{ tag: 's'},
{ tag: 'sup'},
{ tag: 'sub'},
{ tag: 'kbd'},
{ tag: 'abbr'},
{ tag: 'strong'},
{ tag: 'code'},
{ tag: 'samp'},
{ tag: 'del'},
{ tag: 'ins'},
{ tag: 'var'},
{ tag: 'ul'},
{ tag: 'ol'},
{ tag: 'hr'},
{ tag: 'pre'},
{ tag: 'time'},
{ tag: 'img'},
{ tag: 'audio'},
{ tag: 'video'},
{ tag: 'blockquote'},
{ tag: 'details'},
] as WCWYSIWYGTag[];
Внимательный читатель, может заметить, что тут не хватает нескольких тегов, например iframe
, object
, script
, ruby
, отсутствует самый популярный тег div
и с ним section
,main
,footer
и еще несколько, в целом ничего не мешает их добавить в тот список, но эти теги не являются частью текстового редактора, если размышлять семантически, в редакторе мы редактируем некий article
в котором семантически может быть footer
,header
,aside
, но с точки зрения текста они роли не сыграют. Возможно в будущих версиях 1+ этого веб-компонента я добавлю какие-то стили и поддержку этих тегов в виде кнопок, а пока их можно разместить только переключившись в текстовый режим редактора.
Разобравшись со всеми тегами осталось дать пользователю выбирать их через атрибут data-allow-tags
//Получаем теги из аттрибута если есть
const allowTags = this.getAttribute('data-allow-tags') || allTags.map(t => t.tag).join(',');
//...
//Собираем теги в массив
this.EditorAllowTags = allowTags.split(',');
//Формируем итоговый WCWYSIWYGTag[]
this.EditorTags = allTags.filter(tag => allowTags.includes(tag.tag));
И осталось описать функцию, которая соберет нам кнопки, тк собирать кнопки нам придется еще не 1 раз, сделаем два аргумента для фунцкции, элемент в который собираем кнопки и набор кнопок (тегов), благодаря функции el код выглядит очень просто
#makeActionButtons(toEl:HTMLElement, actions:WCWYSIWYGTag[]) {
for (let i = 0; i < actions.length; i++) {
const action = actions[i];
const button = el('button', {
classList: ['wc-wysiwyg_btn', `-${action.tag}`],
props: {
tabIndex: -1,
type:'button',
textContent: action.is ? `${action.tag} is=${action.is}` : action.tag,
onpointerup: (event) => this.#tag(action.tag, event, action.is),
},
attrs: {
'data-hint': action.hint ? action.hint : this.#t(action.tag) || '-',
}
});
toEl.appendChild(button);
}
}
Функция достаточно проста, в цикле создаем кнопки и привязываем с помощью стрелочных функций и onpointerup действия к ним. Абстрактно, мы всегда будем вызывать действие #tag а уже внутри этого метода разбираться, что будем делать с этим тегом. Рассмотрим функцию #tag
#tag = (tag:string, event:Event|false = false, is:boolean|string = false) => {
switch (tag) {
case 'audio':
this.#Media('audio');
break;
case 'video':
this.#Media('video');
break;
case 'details':
this.#Details();
case 'img':
this.#Image();
break;
default:
this.#wrapTag(tag, is);
break;
}
}
Тоже все очень просто, мы перебираем доступные варианты действия над тегом, мы можем его или обернуть с поправкой на тег или вставить тег самостоятельно с поправкой на особенности тега (или custom-element), на весь набор тегов выходит 4 метода для Audio\Video, img и details, в остальном мы можем просто создать тег и обернуть текст в него. Рассмотрим обработку блочного элемента на примре Audio/Video.
#Media = (tagName:string) => { const mediaSrc = prompt('src', ''); if(mediaSrc === '') { return false; } const mediaEl = el(tagName, { attrs: { controls: true }, props: { src: mediaSrc } } ); this.EditorNode.append(mediaEl); this.updateContent(); }
Т.к. минимализм наше все, в место модальных окон я буду использовать prompt чтобы не раздувать редактор очередным изобретением модального окна с input.
#wrapTag = (tag, is:boolean|string = false) => { const listTag = ['ul', 'ol'].includes(tag) ? tag : false; tag = listTag !== false ? 'li' : tag; const Selection = window.getSelection(); let className = null; let defaultOptions = { classList: className ? className : undefined, } as any; if(is) { defaultOptions.options = {is}; } let tagNode = el(tag, defaultOptions); if (Selection !== null && Selection.rangeCount) { if(listTag !== false) { const list = el(listTag); tagNode.replaceWith(list); list.append(tagNode) } const range = Selection.getRangeAt(0).cloneRange(); range.surroundContents(tagNode); Selection.removeAllRanges(); Selection.addRange(range); //If selection has text, insert it if(Selection.toString().length === 0) { tagNode.innerText = tag; } this.updateContent(); } }
А вот с методом #wrapTag все немного сложнее, но концептуально он похож на метод #Media, с нескольими исключениями
- чтобы не добавлять отдельный метод для списков и поддерживать возможность обернуть тест в список и получить список из элемента который был выделен в тексте, обработаем это исключение прямо в этом методе
- Многие пользователи сначала нажимают на тег, а потом собираются туда что-то писать, но попасть курсором в пустой тег затруднительно по этому мы обработаем случай
Selection.toString().length === 0
и если текст не был выделен, добавим в новый тег имя этого тега, чтобы было проще потом отредактировать содержимое тега - Оборачивать в текст можно не только в простой тег, но и в custom-element так что добавим и поддержку is для автономных веб-компонентов, а для custom-elements просто обернем текст в этот тег, под оборачиванием в текст я имею в виду конструкцию range.surroundContents(tagNode);
Отлично! на этом этапе, мы уже имеем базовый функционал и можем вставлять теги в наш EditorNode и оборачивать в теги существующий текст, давайте сразу проработаем кнопку отмены вставки, тот случай, когда мы хотим снять с части текста обрамление каким-то тегом. Создадим наш ClearFormatButton
this.EditorClearFormatBtn = el('button', { classList: ['wc-wysiwyg_btn', '-clear'], attrs: { 'data-hint': this.#t('clearFormat'), }, props: { innerHTML:'Ⱦ', }, });
По умолчанию кнопка очистки формата не имеет собственного слушателя событий, ее работа будет зависеть от текущего выделенного тега в редакторе, добавим в нашу область редактирования EditorNode слушатель onpointerup, обработку события очистки формата, а также проверку возможности редактировать по выбранному элементу, в целом весь NodeEditor редактора в базовой версии будет выглядеть так
this.EditorNode = el('article', { classList: ['wc-wysiwyg_content', this.getAttribute('data-content-class') || ''], props: { contentEditable: true, //Поведение при клике в области редактирования onpointerup: event => { this.checkCanClearElement(event); if(this.#EditProps) { this.checkEditProps(event); } }, //Обновляем контент по input событию oninput: event => { this.updateContent(); if(this.#Autocomplete) { this.#checkAutoComplete(); } }, //Проверяем сочетания клавиш нажатых в редакторе onkeydown: event => { this.#checkKeyBindings(event) } }, });
Вернемся к нашей функции форматирования текста, мое повествование идет в порядке наращивания функционала, по этому мы рассматриваем код не в той очередности, в которой вы его видите в git репозитории
#checkCanClearElement(event:Event) { const eventTarget = event.target as HTMLElement; if(eventTarget !== this.EditorNode) { if(eventTarget.nodeName !== 'P' && eventTarget.nodeName !== 'SPAN') { this.EditorClearFormatBtn.style.display = 'inline-block'; this.EditorClearFormatBtn.innerHTML = `Ⱦ ${eventTarget.nodeName}`, this.EditorClearFormatBtn.onpointerup = (event) => { eventTarget.replaceWith(document.createTextNode(eventTarget.textContent)); } this.showEditorInlineDialog(); } else { this.EditorClearFormatBtn.style.display = 'none'; this.EditorClearFormatBtn.onpointerup = null; } } }
В момент нажатия на элемент, мы проверяем что нажатие произошло не в P или SPAN это единственные два тега, которые мы не будем очищать, для остальных мы в кнопку очистки формата подставим текущий тег и добавим уже здесь слушатель события нажатия, сама очистка тега выглядит очень просто, мы меняем тег на textNode и получаем просто текст document.createTextNode(eventTarget.textContent). Из минусов такого решения можно выделить, что очистка формата происходит только над 1 тегом и пользователь не может очистить формат сразу нескольких тегов в глубину (parentElements). На этом этапе мы получили CRUD действия над тегами, их можно вставлять\оборачивать в тег и можно удалять, осталось проработать U — Update а именно, редактирование свойств тегов, ведь некоторые теги без атрибутов не имеют семантического смысла и ли теряют функциональность
Редактирование атрибутов тегов
О том, в какой момент мы проверяем нажатие на тег мы уже проговорили, в этот же момент мы также проверяем можем ли мы редактировать атрибуты у тега. Для начала пробросим JSON строку вида {a: [«href», «class», «target»]} которая содержит объект, где ключом является имя тега, а значением массив строк в виде имен атрибутов, которые мы допускаем к редактированию в редакторе
#checkEditProps(event) { const eventTarget = event.target as HTMLElement; //Проверяем eventTarget доступен ли такой тег для редактирования if(this.#EditProps[eventTarget.nodeName]) { const props = this.#EditProps[eventTarget.nodeName]; event.stopPropagation(); //Показываем форму редактирования пропсов и наш инлайн диалог this.EditorPropertyForm.style.display = ''; this.showEditorInlineDialog(); //создаем в цикле набор инпутов каждый из которых биндим на свой аттрибут, не забываем очистить форму перед этим this.EditorPropertyForm.setAttribute('data-tag', eventTarget.nodeName); this.EditorPropertyForm.innerHTML = ''; for (let i = 0; i < props.length; i++) { const tagProp = props[i]; const isAttr = tagProp.indexOf('data-') > -1 || tagProp === 'class'; this.EditorPropertyForm.append(el('label', { props: { innerText: `${tagProp}=` }, append: [ el('input', { attrs: { placeholder: tagProp }, classList: ['wc-wysiwyg_inp'], props: { value: isAttr ? eventTarget.getAttribute(tagProp) : eventTarget[tagProp] || '', oninput: (eventInput) => { const eventInputTarget = eventInput.target as HTMLInputElement; //Чтобы пользователь мог вводить несколько классов одной строкой, будем подставлять класс через className if(tagProp === 'class') { eventTarget.className = eventInputTarget.value; } //Тут же обработаем исключение для datetime if((isAttr || tagProp === 'datetime') && eventInputTarget !== null) { eventTarget.setAttribute(tagProp, eventInputTarget.value) } else { eventTarget[tagProp] = eventInputTarget.value; } this.updateContent(); } } }) ] })); } //Добавляем кнопку отправки нашей формы для поддержания привычного UX this.EditorPropertyForm.append(el('button', { classList: ['wc-wysiwyg_btn'], props: { type: 'submit', innerHTML: '↳', }, })); } }
Не спешите пролистывать код, только на webislife я оставляю русские комментарии к коду, в гитхабе все на английском и комментариев меньше. К этому моменту мы получили полноценный MVP, осталось разрешить всем элементам редактировать class и можно дальше просто обвешать текст классами из вашего CSS. Это была первая часть публикации, во второй части я рассмотрю реализацию фишек и удобств для редактора, чтобы сделать его по настоящему функциональным, удобным и легковесным веб-компонентом.
Последняя редакция 18 февраля, 2023 в 04:02