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

Вступление

b17a577d396f17235ec7e297316b0405.png

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

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

  • Теги могут быть одиночные и с закрывающим тегом


    или строка

  • Фундаментально поведение тега в верстке определяется его 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 => 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: '&#8627;',
            },
        }));
    }
}

Не спешите пролистывать код, только в статье я оставляю русские комментарии к коду, на 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);

© Habrahabr.ru