strokoff

Создаем свой веб-компонент 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 базовых действия в редакторе

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

    1. чтобы не добавлять отдельный метод для списков и поддерживать возможность обернуть тест в список и получить список из элемента который был выделен в тексте, обработаем это исключение прямо в этом методе
    2. Многие пользователи сначала нажимают на тег, а потом собираются туда что-то писать, но попасть курсором в пустой тег затруднительно по этому мы обработаем случай Selection.toString().length === 0 и если текст не был выделен, добавим в новый тег имя этого тега, чтобы было проще потом отредактировать содержимое тега
    3. Оборачивать в текст можно не только в простой тег, но и в 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: '&#8627;',
                },
            }));
        }
    }

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


    Последняя редакция 18 февраля, 2023 в 04:02