WYSIWYG / Feast / JSSDK

WYSIWYG

WYSIWYG

Произносится [ˈwɪziwɪɡ], является аббревиатурой от
англ. What You See Is What You Get, «что видишь, то и получишь».

WYSIWYG

WYSIWYG

И так, казалось бы всё просто, задаём `contentEditable` и вуаля, содержимое элемента прекрасно редактируется, так же из коробки работают некотоыре хоткеи, например `cmd+B`.

WYSIWYG

Кроме этого, есть document.execCommand, при помощи которого вы можете менять размер/шрифт/цвет текста и много чего другого.

👍

Пробуем

			<div contentEditable="true">
				Давай, нажми enter!
			</div>
		

Вау!
Срочно в бой!

Спустя время...

Спустя время...

Что же делать?

Готовые решения

CKEditor

			Starting CKBuilder...
			Cleaning up target folder
			Copying files (relax, this may take a while)
				Time taken.....: 13.688 seconds
			Merging language files
				Time taken.....: 3.536 seconds
			Generating plugins sprite image
			Building ckeditor.js
			Created ckeditor.js (**1 536 KB**)
				Time taken.....: 5.269 seconds
			Building skins
			Cleaning up target folder
			==========================
			Release process completed:
				Number of files: 122
				Total size.....: **3 503 854 bytes**
				Time taken.....: 22.964 seconds
		

1.5 MiB 😱
или ~173 KiB в gzip

Мебибайт кода

Такой размер имеют практически все подобные решения, дело в том, что они не просто обертка над `contentEditable`, кроме этого, они имеют свои абстракции для работы: Selection, Range, DOM, execCommand и т.п.

Кроме этого, они имеют файловые менеджеры, модальный окна, богатый инструментарий для разработки UI и многое другое.

Мебибайт кода

Да, тот же CKEditor имеет модульную структуру и строится на базе расширений, но расширение идет как единый модуль:
  • Команда
  • UI (кнопки тулбара, работа с формами, диалогами и ...)
  • Локализация и т.п.
Т.е. нельзя просто собрать «голый» редактор, собрать придётся всё, можно только выключить отображение UI конкретного расширения.

Мебибайт кода

Кроме этого, как показывает практика, каждый редактор имеет свои особенности при взаимодействии с текстом, а ещё стоит много 💰💰💰

И?

Что это значит для нас?

На данный момент мы используем TinyMCE и хотим перейти на CKEditor, но чтобы не получил как в прошлый раз, я постарался сделать наш редактор независимым от конкретного решения.

Для этого я создал

С учётом всех известных проблем, это выглядит следующим образом...

compose-wysiwyg

			import {wysiwyg} from "compose-wysiwyg";
			#! import CKEDITOR from "ckeditor-clean";
			#! import ckeditorAdapter from "compose-wysiwyg/adapter/ckeditor";
			#! import whiteTheme from "compose-wysiwyg/theme/white";

			#!+ wysiwyg({
				el: document.getElementById("root"),
				#! engine: **ckeditorAdapter(CKEDITOR)**,
				#! theme: **whiteTheme()**,
			}).then(editor => {
				#!+ // Установка контента
				#!- editor.setContent("<br/><h1>Hi!</h1>");
				#!+
				// Курсор в начало строки
				const firstChild = editor.getEditableContainer().firstChild;
				#!- editor.setCursor(firstChild, "before");
			#!- });
		

compose-wysiwyg / Адаптер

compose-wysiwyg / Тема

			import {createTheme} from "./_theme";

			export default createTheme(`
				.editor { background: #fff; }

				.editable {
					font-size: 15px;
					font-family: "San Francisco", Arial, sans-serif;
				}

				.toolbar { padding: 5px 10px; }

				// И так далее
			`);
		

Что дальше?

Что дальше?

А дальше... дальше меня всё больше и больше начала разбирать любопытство, что же там такое, как вставить BR и сделать текст жирным ;]

Что нужно знать?

			#! const selection = window.getSeelction();
			#! const range = selection.getRangeAt(0);
			#! // ...
			#! selection.removeAllRanges();
			#! selection.addRange(range);
		

Range

				<div>
					Очень <em>полезный</em> и
					<strong>красивый</strong>
					текст!
				</div>
			

Range

			{
				 collapsed: false,
				 commonAncestorContainer: <div>
				 startContainer: <em>,
				 startOffset: 6,
				 endContainer: <strong>,
				 endOffset: 3,
			}
		

...

Как видите, всё очень просто, работа с выделение имеет прерасное API, так почему не попробовать написать это самому.

План

<BR/>

<BR/>

  1. Получаем startContainer и startOffset из range
  2. Если startContainer не текст, то берем childNdes[startOffset], иначе
    • если курсор в конце, перемещаемся к следующей ноде
    • либо разбиваем ноду попалам и берем правую часть
    После этого проверям полученную ноду, если это текст и она пустая, то заменяем на ZWS.
  3. Вставляем <BR/> перед найденной нодой
  4. После вставки проверяем следущий элемент после <BR/>, если его нет, или это не инлайн, то добавляем ZWS.

И так далее

applyStyle

Тут должно много букф, но я просто не осилю рассказать всё то, что узнал, но теперь точно могу сказать: «Да, я умею работать с DOM!».

applyStyle / Пара примеров

			<b style="color: black">x(-+)y</b>
			           ↓ ↓ ↓
			<b style="color: black">x</b>
			<b style="color: red">(-+)</b>
			<b style="color: black">y</b>
		

applyStyle / Пара примеров

			<span style="color: black">(x-</span>
			<span style="color: green">+y)</span>
			           ↓ ↓ ↓
			<span style="color: black">(x-+y)</span>
		

applyStyle / Пара примеров

			<div>Очень <em>полез(ный</em> <u>или</u></div>
			<strong>кра)сивый</strong>
			           ↓ ↓ ↓
			<div>Очень <em>полез**<b>(ный</b>**</em>**<b> <u>или</u></b>**</div>
			<strong>**<b>кра)</b>**сивый</strong>
		

applyStyle / Тонкости

			// есть текст: foo -> <b>foo</b>
			#! range.setStart(text, 0);
			#! range.setEnd(text, 3);
			#! range.surroundContents(document.createElement("b"));
			#! // But...
			#!+ console.log(rootContainer.childNodes);
			#!- // [text, b, text] мдя...
		

applyStyle / Тонкости

			// есть текст: foo -> <b>foo</b>
			#! range.setStartBefore(text);
			#! range.setEndAfter(text);
			#!+ range.surroundContents(document.createElement("b"));
			console.log(rootContainer.childNodes);
			#!- // [b]
		

Reviser

Reviser

Это моя попытка написать API для работы с Range и на базе этого API создать WYSIWYG. Он не имеет UI и в изначальной конфигурации умеет только:

  1. Выставить contentEditable
  2. Изменить/получить содержимое
  3. Передвинуть кусор
  4. Запоминать последний Range

Reviser

Reviser

			import Reviser, {CARET_AT_END} from "reviser";
			#!import reviserExtensionBasePack from "reviser/extensions/base-pack";

			#!+ const container = document.getElementById("root");
			const reviser = new Reviser(container, {
				#! extensions: reviserExtensionBasePack,
				#! defaultCaretPosition: CARET_AT_END,
			#!- });

			#!+ reviser.content = "x<div></div>y <b>http</b>://mail.ru";
			#!- reviser.focus();
		

Reviser / extensions / base-pack

			import zwsFactory from "reviser/extensions/base-pack/zws/zws";
			import linkFactory from "reviser/extensions/base-pack/link/link";
			import enterFactory from "reviser/extensions/base-pack/enter/enter";
			import backspaceFactory from "reviser/extensions/base-pack/backspace/backspace";
			import baseStyleFactory from "reviser/extensions/base-pack/base-style/base-style";
			
			export default [
				zwsFactory(),
				linkFactory(),
				enterFactory(),
				backspaceFactory(),
				baseStyleFactory({bold: true, italic: true, underline: true}),
			];
		

Reviser / Дружим с UI

			<div id="toolbar">
				<button data-action="bold">B</button>
				<button data-action="italic">I</button>
				<button data-action="underline">U</button>
			</div>
		

Reviser / Дружим с UI

			import {listenForm} from "reviser/pen-box/dom";
			import commandBold from "reviser/commands/bold";
			import commandItalic from "reviser/commands/italic";
			import commandUnderline from "reviser/commands/underline";

			#!+ const actions = {
				"bold": commandBold,
				"italic": commandItalic,
				"underline": commandUnderline,
			#!- };

			#!+ export function linkReviserAndUI(reviser, toolbar) {
				#!+ listenForm(toolbar, "click", "data-action", (actionName, target, evt) => {
					#! const range = reviser.getSelectionRange();

					#! actions[actionName](range);
					#! reviser.revertFocus(range);
				#!- });
			#!- };
		

Reviser / На данный момент

Feast — основные моменты

Feast v1.0.0

Feast / TypeScript

			import {Block} from "feast";
			import Icon from "../blocks/icon/icon";

			interface IButton {
				icon?: string;
				value: string;
			}

			export class Button extends Block<IButton> {
				name: "button",
				blocks: {Icon},
				template: `
					<div>
						<Icon fn:if="attrs.icon" name="{attrs.icon}"/>
						{attrs.value}
					</div>
				`,
			}
		

Feast / TypeScript + JSX

			import {Block} from "feast";
			import Icon from "../blocks/icon/icon";

			interface IButton {
				icon?: string;
				value: string;
			}

			export class Button extends Block<IButton> {
				name: "button",
				template: ({icon, value}, React) => (
					<div>
						{icon && <Icon name={icon}/>}
						{value}
					</div>
				),
			}
		

JSSDK / Базовые принципы

JSSDK / Действие

			var MyCoolAction = Action.extend(/** @lends MyCoolAction# */{
				#!+ // Подготовка данных
				prepare: function (params, options) {
					return data; // либо просто `params`, если готовить ничего не нужно
				#!- },
				#!+ // Выполняемая операция
				operation: function (data, params, options) {
					// ...
				#!- },
				#!+ // Откатываем изменения в случае ошибки при выполнении операции
				rollbackOperation: function () {
					// ...
				#!- },
				#!+ // Обратная операция
				undoOperation: function (data, params, options) {
					// ...
				#!- },
			});
		

JSSDK / Действие

			var MyCoolAction = Action.extend(/** @lends MyCoolAction# */{
				// Подготовка данных
				prepare: function (params, options) {
					return Folder.findOne(params.folder);
				},

				#!+ // Выполняемая операция
				operation: function (folder, params, options) {
					return RPC.call(Foler.url("myCoolAction"), {
						flag: params.flag,
						state: params.state,
					});
				#!- }
			});
		

JSSDK / Действие

			MyCoolAction.execute({
				folder: Folder.INBOX,
				flag: "foo",
				state: false,
			}).then(action => {
				// ...
				console.log(action.result);
				// и
				return action.undo();
			});
		

The End