Пишем собственный WYSIWYG редактор на основе веб-компонентов и textarea. Часть 1

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Вступление

Всем привет, последние пару месяцев я активно изучаю тему веб-компонентов, собираю и нарабатываю опыт, а затем делюсь своими наработками с другими с целью обменяться опытом, получит новый опыт, фидбек и понять куда двигается разработка в вебе и шагать дальше за новым опытом. Все ниже изложенное не является инструкцией как делать нужно, а является примером того, как сделать возможно на текущий момент в 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 версия, все цвета прописаны через переменные, цветовая палитра взята отсюда.

Базовые функции редактора

  • Теги могут быть одиночные и с закрывающим тегом <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 редактора, или относится косвенно в виде дополнительных возможностей, мы же начнем с азов и редактирования текста и постараемся вообще не вмешиваться в редактируемый DOM контента, чтобы не портить пользовательский UX и дать работать с чистым HTML, что например уже нельзя в навороченном новом редакторе хабра и текст мне для статьи пришлось переносить поблочно из уже частично готово HTML5

Пример минимального блока в редакторе хабра в сравнении с просто тегом article с contenteditable=true в wc-wysiwyg, стоит отметить, что .node__inner не умеет работать с двумя P на этом его полномочия заканчиваются и приходится создавать новый блок через UI редактора хабра
Пример минимального блока в редакторе хабра в сравнении с просто тегом article с contenteditable=true в wc-wysiwyg, стоит отметить, что .node__inner не умеет работать с двумя P на этом его полномочия заканчиваются и приходится создавать новый блок через UI редактора хабра

Реализуем вставку тегов

Для реализации вставки в тегов в редактор, необходимо рассказать редактору, о каких тегах он должен знать по умолчанию, у меня получился вот такой вот список

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 =&gt; allowTags.includes(tag.tag));

И осталось описать функцию, которая соберет нам кнопки, тк собирать кнопки нам придется еще не 1 раз, сделаем два аргумента для фунцкции, 1 элемент в который собираем кнопки и 2 набор кнопок (тегов), благодаря функции el код выглядит очень просто

#makeActionButtons(toEl:HTMLElement, actions:WCWYSIWYGTag[]) {
    for (let i = 0; i &lt; 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) =&gt; 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' 
        &amp;&amp; eventTarget.nodeName !== 'SPAN') {
            this.EditorClearFormatBtn.style.display = 'inline-block';
            this.EditorClearFormatBtn.innerHTML = `Ⱦ ${eventTarget.nodeName}`,
            this.EditorClearFormatBtn.onpointerup = (event) =&gt; {
                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 &lt; props.length; i++) {
            const tagProp = props[i];
            const isAttr = tagProp.indexOf('data-') &gt; -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) =&gt; {
                                const eventInputTarget = eventInput.target as HTMLInputElement;
                                //Чтобы пользователь мог вводить несколько классов одной строкой, будем подставлять класс через className
                                if(tagProp === 'class') {
                                    eventTarget.className = eventInputTarget.value;
                                }
                                //Тут же обработаем исключение для datetime
                                if((isAttr || tagProp === 'datetime') &amp;&amp; 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: '&amp;#8627;',
            },
        }));
    }
}

Не спешите пролистывать код, только в статье я оставляю русские комментарии к коду, на github все на английском и комментариев меньше. К этому моменту мы получили полноценный MVP, осталось разрешить всем элементам редактировать class и можно дальше просто обвешать текст классами из вашего CSS и будет вам счастье :) шучу конечно, больше фишек и возможностей на текущий момент читайте в Readme.md

Это была первая часть публикации, во второй части я рассмотрю реализацию фишек и удобств для редактора, чтобы сделать его по настоящему функциональным, удобным и легковесным веб-компонентом, расскажу про фидбек от сообществ из телеграм каналов, упомяну опыт интеграции в настоящие сайты большие и маленькие и даже в гости к $mol узнать как дела у них с веб-компонентами я заглянул, т.к. там тоже про opensource вродебы ;)

Заключение

Хочу в конце статьи еще раз напомнить, что версия компонента 0.9.33 что как бы намекает, что для версии 1 еще сыроват компонент, но практическое применение и первых пользователей, а также пару сотен установок в npm и пару звезд на гитхабе он уже нашел, что дает мне силы и мотивацию продолжать развивать это дело на некоммерческой основе. Никаких донатов как некоторые опенсус разработчики под обещания я не собираю и не буду, просто так на чай тоже не нужно, у меня есть любимые галеры с комфортной з.п. а это просто часть развития кругозора)

p.s. все что буду находить и считать полезным и нужным я буду складывать вот тут, не стесняйтесь предлагать свои ссылки в цикл для расширения кругозора и обмена опытом

p.p.s как и обещал попытка вставить HTML5 простые теги в хабр статью - ДемонстABBR</p>" data-abbr="р">рация и обзор возможностей веб-компонента wc-wysiwyg - сравните с демкой) за раз всего не рассказать, постараюсь ответить на все вопросы в комментариях) have fun!

  • Многие пользователи сначала нажимают на тег, а потом собираются туда что-то писать, но попасть курсором в пустой тег затруднительно по этому мы обработаем случай Selection.toString().length === 0 и если текст не был выделен, добавим в новый тег имя этого тега, чтобы было проще потом отредактировать содержимое тега

  • Оборачивать в текст можно не только в простой тег, но и в custom-element так что добавим и поддержку is для автономных веб-компонентов, а для custom-elements просто обернем текст в этот тег, под оборачиванием в текст я имею в виду конструкцию range.surroundContents(tagNode);

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Вы используете веб-компоненты?
0% Да, уже давно 0
50% Пользуюсь фреймворками и веб-компонентами 1
0% Считаю веб-компоненты устаревшей технологией 0
50% Не использовал, но стало интересно 1
0% Только в домашних пет проектах 0
Проголосовали 2 пользователя. Воздержавшихся нет.
Источник: https://habr.com/ru/post/716986/


Интересные статьи

Интересные статьи

Тысячи китайских иероглифов в условиях ограниченного количества оперативной памяти заставили инженеров Sinotype III существенно раздвинуть границы возможного для первых персональных компьютеров.Сегодн...
Сегодня я покажу вам, как запустить полноценный резервный сервер на рутованном телефоне Android с помощью UrBackup и Linux Deploy. Пластиковый мусор уже заполонил все вокруг, а в доб...
Уважаемые хабравчане, предлагаю вашему вниманию перевод оставшихся глав спецификации D-Bus. Полный pdf-документ можно скачать по адресу: https://vk.cc/bYQS6vПоскольку пер...
Сегодня мы хотим поделиться опытом решения задачи детекции дефектов на снимках промышленных объектов методами современного компьютерного зрения. Наш рассказ будет состоять из нескольк...
Пришло время поговорить об устройствах, предназначенных для управления тормозами. Эти устройства называются «кранами», хотя долгий путь эволюции увел их достаточно далеко от кранов в привычном на...