====== Пишем собственный WYSIWYG редактор на основе веб-компонентов и textarea. Часть 1 ======
===== Вступление =====
{{:software:development:web:docs:writing_our_own_wysiwyg_editor_05.png?200 |}} Всем привет, последние пару месяцев я активно изучаю тему веб‑компонентов, собираю и нарабатываю опыт, а затем делюсь своими наработками с другими с целью обменяться опытом, получить новый опыт, фидбек и понять куда двигается разработка в вебе и шагать дальше за новым опытом. Все ниже изложенное не является инструкцией как делать нужно, а является примером того, как сделать возможно на текущий момент в 2023 году, у меня уже набрался небольшой опыт (8 публикаций и 3 веб‑компонента на гитхабе) и я решился попробовать сделать что‑то серьезнее чем просто очередную реактивную кнопку или лайки, в первой части моей публикации я проведу вас по MVP веб‑компонента wc-wisywig, немного затронем философию семантики, браузерные API и обменяемся опытом, потестим HTML5 теги в статье на хабре. Для нетерпеливых сразу вот ссылка на демо и git репозиторий. Остальных ждет техничесий лонгрид, прошу под кат)
===== Техническая основа и база редактора =====
В базовой функциональности редактора, важно предусмотреть фундамент для будущего развития веб‑компонента, а также реализовать работу с API основных возможностей которые дают нам браузеры, но также важно знать меру и не переусердствовать, в качестве базы мы могли бы взять некий bootstrap или tailwind для стилей, а для формочек некий react\vue чтобы не морочиться с биндингом данных, а еще затащить иконочный шрифт чтобы не морочиться с иконками, но тогда весь фундаментальный смысл расширяемости просто бы пропал, зато появилась необходимость поддерживать версии библиотек в node_modules, сегодняшний пост совсем не об этом, мы будем писать на TypeScript используя ESNext стиль и вообще не будем использовать полифилы. Но все‑таки чтобы не писать много лапши и получить код с хорошей читаемостью и оформлением, я воспользуюсь самодельной функцией el которая просто будет выполнять действия над возвращаемым Element из функции document.createElement
В каком‑то смысле можно сказать, что веб‑компонент wc-wysiwyg написан на функциональных компонентах основанных на браузерном DOM, в модном ныне SSR этому компоненту делать нечего, он просто добавляет возможностей к редактированию текста внутри textarea на клиенте.
/**
* Short document.createElement
* @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 дерева, я не копировал и не переделывал функции из фреймворков, так сказать вдохновился на опыте использования.
Также в базе у нас будет 2 файла со стилями в одном файле будут стили для самого редактора, а во втором файле будут базовые стили для тегов. Сами стили написаны с использованием SASS, но в репозитории также доступна и CSS версия, все цвета прописаны через переменные, цветовая палитра взята [[:html_color_table|отсюда.]]
===== Базовые функции редактора =====
Редактор в качестве основы будет поддерживать семантику HTML5 доступных нам тегов, а это значит что, бы стоило начать с тегов. Что мы знаем о HTML5 тегах в общих чертах?
* Теги могут быть одиночные и с закрывающим тегом
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[];
Если вам, как и мне хочется этот листинг превратить в простой массив, то обратите внимание на тип **WCWYSIWYGTag** в котором я заложил еще **hint, is, method** которые пригодятся позже чтобы реализовать в веб‑компоненте поддержку других веб‑компонентов)
Внимательный читатель, может заметить, что тут не хватает нескольких тегов, например **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 раз, сделаем два аргумента для фунцкции, 1 элемент в который собираем кнопки и 2 набор кнопок (тегов), благодаря функции 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:WCWYSIWYGTag) => {
switch (tag.tag) {
case 'audio':
this.#Media('audio');
break;
case 'video':
this.#Media('video');
break;
case 'details':
this.#Details();
case 'img':
this.#Image();
break;
default:
if(typeof tag.method === 'function') {
tag.method.apply(this, tag);
} else {
this.#wrapTag(tag, 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 чтобы не раздувать редактор очередным изобретением модального окна с одним полем ввода, хотя с el функцией это выглядело бы не так сложно.
А вот с методом #wrapTag все немного сложнее, но концептуально он похож на метод #Media, с нескольими исключениями:
#wrapTag = (tag, is:boolean|string = false) => {
//Обработаем случай, когда оборачивают в список, то текст будет в li а сверху добавим ol/ul
const listTag = ['ul', 'ol'].includes(tag) ? tag : false;
tag = listTag !== false ? 'li' : tag;
const Selection = window.getSelection();
let className = null;
//подготовим параметры по умолчанию для создания el
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.toString().length === 0) {
tagNode.innerText = tag;
}
this.updateContent();
}
}
Чтобы не добавлять отдельный метод для списков и поддерживать возможность обернуть тест в список и получить список из элемента который был выделен в тексте, обработаем это исключение прямо в этом методе.
Многие пользователи сначала нажимают на тег, а потом собираются туда что‑то писать, но попасть курсором в пустой тег затруднительно по этому мы обработаем случай ''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'' редактора в базовой версии будет выглядеть так:
//.... в connectedCallback()
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: '↳',
},
}));
}
}
Не спешите пролистывать код, только в статье я оставляю русские комментарии к коду, на github все на английском и комментариев меньше. К этому моменту мы получили полноценный MVP, осталось разрешить всем элементам редактировать class и можно дальше просто обвешать текст классами из вашего CSS и будет вам счастье:) шучу конечно, больше фишек и возможностей на текущий момент читайте в Readme.md
Это была первая часть публикации, во второй части я рассмотрю реализацию фишек и удобств для редактора, чтобы сделать его по настоящему функциональным, удобным и легковесным веб‑компонентом, расскажу про фидбек от сообществ из телеграм каналов, упомяну опыт интеграции в настоящие сайты большие и маленькие и даже в гости к ''$mol'' узнать как дела у них с веб‑компонентами я заглянул, т.к. там тоже про opensource вродебы;)
===== Заключение =====
Хочу в конце статьи еще раз напомнить, что версия компонента 0.9.33 что как бы намекает, что для версии 1 еще сыроват компонент, но практическое применение и первых пользователей, а также пару сотен установок в npm и пару звезд на гитхабе он уже нашел, что дает мне силы и мотивацию продолжать развивать это дело на некоммерческой основе. Никаких донатов как некоторые опенсус разработчики под обещания я не собираю и не буду, просто так на чай тоже не нужно, у меня есть любимые галеры с комфортной з.п. а это просто часть развития кругозора)
P. S. все что буду находить и считать полезным и нужным я буду складывать вот тут, не стесняйтесь предлагать свои ссылки в цикл для расширения кругозора и обмена опытом
p.p.s как и обещал попытка вставить HTML5 простые теги в хабр статью — Демонстрация и обзор возможностей веб‑компонента wc‑wysiwyg — сравните с демкой) за раз всего не рассказать, постараюсь ответить на все вопросы в комментариях) have fun!
* Многие пользователи сначала нажимают на тег, а потом собираются туда что-то писать, но попасть курсором в пустой тег затруднительно по этому мы обработаем случай ''Selection.toString().length === 0'' и если текст не был выделен, добавим в новый тег имя этого тега, чтобы было проще потом отредактировать содержимое тега
* Оборачивать в текст можно не только в простой тег, но и в custom-element так что добавим и поддержку is для автономных веб-компонентов, а для ''custom-elements'' просто обернем текст в этот тег, под оборачиванием в текст я имею в виду конструкцию range.surroundContents(tagNode);
===== Дополнения и Файлы =====
* [[https://habr.com/ru/articles/716986/|Ссылка на оригинальную статью]]
* [[gitea>]][[https://git.wwoss.ru/VladPolskiy/wc-wysiwyg.git|wc-wysiwyg]]