feat(tiptap): editor controller

This commit is contained in:
Paul Chavard 2023-11-15 17:18:45 +01:00
parent 216e2f9198
commit ae7fc056f5
8 changed files with 637 additions and 241 deletions

View file

@ -0,0 +1,116 @@
import { Editor } from '@tiptap/core';
import { z } from 'zod';
type EditorAction = {
run(): void;
isActive(): boolean;
isDisabled(): boolean;
};
export function getAction(
editor: Editor,
button: HTMLButtonElement
): EditorAction {
return tiptapActionSchema.parse(button.dataset)(editor);
}
const EDITOR_ACTIONS: Record<string, (editor: Editor) => EditorAction> = {
title: (editor) => ({
run: () => editor.chain().focus(),
isActive: () => editor.isActive('title'),
isDisabled: () => !editor.isActive('title')
}),
heading2: (editor) => ({
run: () => editor.chain().focus().setHeading({ level: 2 }).run(),
isActive: () => editor.isActive('heading', { level: 2 }),
isDisabled: () =>
editor.isActive('title') ||
editor.isActive('header') ||
editor.isActive('footer')
}),
heading3: (editor) => ({
run: () => editor.chain().focus().setHeading({ level: 3 }).run(),
isActive: () => editor.isActive('heading', { level: 3 }),
isDisabled: () =>
editor.isActive('title') ||
editor.isActive('header') ||
editor.isActive('footer')
}),
bold: (editor) => ({
run: () => editor.chain().focus().toggleBold().run(),
isActive: () => editor.isActive('bold'),
isDisabled: () => editor.isActive('heading') || editor.isActive('title')
}),
italic: (editor) => ({
run: () => editor.chain().focus().toggleItalic().run(),
isActive: () => editor.isActive('italic'),
isDisabled: () => false
}),
underline: (editor) => ({
run: () => editor.chain().focus().toggleUnderline().run(),
isActive: () => editor.isActive('underline'),
isDisabled: () => false
}),
strike: (editor) => ({
run: () => editor.chain().focus().toggleStrike().run(),
isActive: () => editor.isActive('strike'),
isDisabled: () => editor.isActive('heading') || editor.isActive('title')
}),
highlight: (editor) => ({
run: () => editor.chain().focus().toggleHighlight().run(),
isActive: () => editor.isActive('highlight'),
isDisabled: () => editor.isActive('heading') || editor.isActive('title')
}),
bulletList: (editor) => ({
run: () => editor.chain().focus().toggleBulletList().run(),
isActive: () => editor.isActive('bulletList'),
isDisabled: () =>
editor.isActive('title') ||
editor.isActive('header') ||
editor.isActive('footer')
}),
orderedList: (editor) => ({
run: () => editor.chain().focus().toggleOrderedList().run(),
isActive: () => editor.isActive('orderedList'),
isDisabled: () =>
editor.isActive('title') ||
editor.isActive('header') ||
editor.isActive('footer')
}),
left: (editor) => ({
run: () => editor.chain().focus().setTextAlign('left').run(),
isActive: () => editor.isActive({ textAlign: 'left' }),
isDisabled: () => false
}),
center: (editor) => ({
run: () => editor.chain().focus().setTextAlign('center').run(),
isActive: () => editor.isActive({ textAlign: 'center' }),
isDisabled: () => false
}),
right: (editor) => ({
run: () => editor.chain().focus().setTextAlign('right').run(),
isActive: () => editor.isActive({ textAlign: 'right' }),
isDisabled: () => false
}),
justify: (editor) => ({
run: () => editor.chain().focus().setTextAlign('justify').run(),
isActive: () => editor.isActive({ textAlign: 'justify' }),
isDisabled: () => false
}),
undo: (editor) => ({
run: () => editor.chain().focus().undo().run(),
isActive: () => false,
isDisabled: () => !editor.can().chain().focus().undo().run()
}),
redo: (editor) => ({
run: () => editor.chain().focus().redo().run(),
isActive: () => false,
isDisabled: () => !editor.can().chain().focus().redo().run()
})
};
const tiptapActionSchema = z
.object({
tiptapAction: z.enum(Object.keys(EDITOR_ACTIONS) as [string, ...string[]])
})
.transform(({ tiptapAction }) => EDITOR_ACTIONS[tiptapAction]);

View file

@ -0,0 +1,139 @@
import Document from '@tiptap/extension-document';
import Hystory from '@tiptap/extension-history';
import TextAlign from '@tiptap/extension-text-align';
import Gapcursor from '@tiptap/extension-gapcursor';
import Paragraph from '@tiptap/extension-paragraph';
import BulletList from '@tiptap/extension-bullet-list';
import OrderedList from '@tiptap/extension-ordered-list';
import ListItem from '@tiptap/extension-list-item';
import Text from '@tiptap/extension-text';
import Highlight from '@tiptap/extension-highlight';
import Underline from '@tiptap/extension-underline';
import Bold from '@tiptap/extension-bold';
import Italic from '@tiptap/extension-italic';
import Strike from '@tiptap/extension-strike';
import Mention from '@tiptap/extension-mention';
import Typography from '@tiptap/extension-typography';
import Heading from '@tiptap/extension-heading';
import {
Editor,
type EditorOptions,
type JSONContent,
type Extensions
} from '@tiptap/core';
import {
DocumentWithHeader,
Title,
Header,
Footer,
HeaderColumn
} from './nodes';
import { createSuggestionMenu, type TagSchema } from './tags';
export function createEditor({
editorElement,
content,
tags,
buttons,
onChange
}: {
editorElement: Element;
content?: JSONContent;
tags: TagSchema[];
buttons: string[];
onChange(change: { editor: Editor }): void;
}): Editor {
const options = getEditorOptions(editorElement, tags, buttons, content);
const editor = new Editor(options);
editor.on('transaction', onChange);
return editor;
}
function getEditorOptions(
element: Element,
tags: TagSchema[],
actions: string[],
content?: JSONContent
): Partial<EditorOptions> {
const extensions: Extensions = [];
for (const action of actions) {
switch (action) {
case 'bold':
extensions.push(Bold);
break;
case 'italic':
extensions.push(Italic);
break;
case 'underline':
extensions.push(Underline);
break;
case 'strike':
extensions.push(Strike);
break;
case 'highlight':
extensions.push(Highlight);
break;
case 'bulletList':
extensions.push(BulletList);
break;
case 'orderedList':
extensions.push(OrderedList);
break;
case 'left':
case 'center':
case 'right':
case 'justify':
extensions.push(
TextAlign.configure({
types: actions.includes('title')
? ['headerColumn', 'title', 'footer', 'heading', 'paragraph']
: ['heading', 'paragraph']
})
);
break;
case 'title':
extensions.push(Header, HeaderColumn, Title, Footer);
break;
case 'heading2':
case 'heading3':
extensions.push(Heading.configure({ levels: [2, 3] }));
break;
}
}
if (actions.includes('bulletList') || actions.includes('orderedList')) {
extensions.push(ListItem);
}
if (tags.length > 0) {
extensions.push(
Mention.configure({
renderLabel({ node }) {
return `--${node.attrs.label}--`;
},
HTMLAttributes: {
class: 'fr-badge fr-badge--sm fr-badge--info fr-badge--no-icon'
},
suggestion: createSuggestionMenu(tags, element)
})
);
}
return {
element,
content,
editorProps: { attributes: { class: 'fr-input' } },
extensions: [
actions.includes('title') ? DocumentWithHeader : Document,
Hystory,
Typography,
Gapcursor,
Paragraph,
Text,
...extensions
]
};
}

View file

@ -0,0 +1,64 @@
import { Node, mergeAttributes } from '@tiptap/core';
export const DocumentWithHeader = Node.create({
name: 'doc',
topNode: true,
content: 'header title block+ footer'
});
export const Title = Node.create({
name: 'title',
content: 'inline*',
defining: true,
marks: 'italic underline',
parseHTML() {
return [{ tag: `h1`, attrs: { level: 1 } }];
},
renderHTML({ HTMLAttributes }) {
return ['h1', HTMLAttributes, 0];
}
});
export const Header = Node.create({
name: 'header',
content: 'headerColumn headerColumn',
defining: true,
parseHTML() {
return [{ tag: `header` }];
},
renderHTML({ HTMLAttributes }) {
return [
'header',
mergeAttributes(HTMLAttributes, { class: 'header flex' }),
0
];
}
});
export const Footer = Node.create({
name: 'footer',
content: 'paragraph+',
defining: true,
parseHTML() {
return [{ tag: `footer` }];
},
renderHTML({ HTMLAttributes }) {
return ['footer', mergeAttributes(HTMLAttributes, { class: 'footer' }), 0];
}
});
export const HeaderColumn = Node.create({
name: 'headerColumn',
content: 'paragraph',
defining: true,
parseHTML() {
return [{ tag: `div` }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes, { class: 'flex-1' }), 0];
}
});

View file

@ -0,0 +1,173 @@
import type { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion';
import { z } from 'zod';
import tippy, { type Instance as TippyInstance } from 'tippy.js';
import { matchSorter } from 'match-sorter';
export const tagSchema = z
.object({ tagLabel: z.string(), tagId: z.string() })
.transform(({ tagId, tagLabel }) => ({ label: tagLabel, id: tagId }));
export type TagSchema = z.infer<typeof tagSchema>;
class SuggestionMenu {
#selectedIndex = 0;
#props: SuggestionProps<TagSchema>;
#element?: Element;
#popup?: TippyInstance;
constructor(props: SuggestionProps<TagSchema>, editorElement: Element) {
this.#props = props;
this.render();
this.init(editorElement);
}
init(editorElement: Element) {
if (!this.#props.clientRect) {
return;
}
this.#popup = tippy(document.body, {
getReferenceClientRect: () => {
const domRect = this.#props?.clientRect?.();
if (!domRect) {
throw new Error('domRect is null');
}
return domRect;
},
appendTo: editorElement,
content: this.#element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start'
});
}
update(props: SuggestionProps<TagSchema>) {
this.#props = props;
if (!this.#props.clientRect) {
return;
}
this.#popup?.setProps({
getReferenceClientRect: () => {
const domRect = props.clientRect?.();
if (!domRect) {
throw new Error('domRect is null');
}
return domRect;
}
});
this.render();
}
onKeyDown(key: string) {
switch (key) {
case 'ArrowDown':
this.down();
return true;
case 'ArrowUp':
this.up();
return true;
case 'Escape':
this.escape();
return true;
case 'Enter':
this.enter();
return true;
}
return false;
}
destroy() {
this.#popup?.destroy();
this.#element?.remove();
}
private render() {
this.#element ??= this.createMenu();
const list = this.#element.firstChild as HTMLUListElement;
const html = this.#props.items
.map((item, i) => {
return `<li class="fr-badge fr-badge--sm fr-badge--no-icon${
i == this.#selectedIndex ? ' fr-badge--info' : ''
}">${item.label}</li>`;
})
.join('');
this.#element.classList.add('fr-menu__list');
list.innerHTML = html;
list.querySelector<HTMLElement>('.selected')?.focus();
}
private createMenu() {
const menu = document.createElement('div');
const list = document.createElement('ul');
menu.classList.add('fr-menu');
list.classList.add('fr-menu__list');
menu.appendChild(list);
return menu;
}
private up() {
this.#selectedIndex =
(this.#selectedIndex + this.#props.items.length - 1) %
this.#props.items.length;
this.render();
}
private down() {
this.#selectedIndex = (this.#selectedIndex + 1) % this.#props.items.length;
this.render();
}
private enter() {
const item = this.#props.items[this.#selectedIndex];
if (item) {
this.#props.command(item);
}
}
private escape() {
this.#popup?.hide();
this.#selectedIndex = 0;
}
}
export function createSuggestionMenu(
tags: TagSchema[],
editorElement: Element
): Omit<SuggestionOptions<TagSchema>, 'editor'> {
return {
char: '@',
items: ({ query }) => {
return matchSorter(tags, query, { keys: ['label'] }).slice(0, 6);
},
render: () => {
let menu: SuggestionMenu;
return {
onStart: (props) => {
menu = new SuggestionMenu(props, editorElement);
},
onUpdate(props) {
menu.update(props);
},
onKeyDown(props) {
return menu.onKeyDown(props.event.key);
},
onExit() {
menu.destroy();
}
};
}
};
}