feat(tiptap): editor controller
This commit is contained in:
parent
216e2f9198
commit
ae7fc056f5
8 changed files with 637 additions and 241 deletions
116
app/javascript/shared/tiptap/actions.ts
Normal file
116
app/javascript/shared/tiptap/actions.ts
Normal 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]);
|
139
app/javascript/shared/tiptap/editor.ts
Normal file
139
app/javascript/shared/tiptap/editor.ts
Normal 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
|
||||
]
|
||||
};
|
||||
}
|
64
app/javascript/shared/tiptap/nodes.ts
Normal file
64
app/javascript/shared/tiptap/nodes.ts
Normal 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];
|
||||
}
|
||||
});
|
173
app/javascript/shared/tiptap/tags.ts
Normal file
173
app/javascript/shared/tiptap/tags.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue