feat(tiptap): editor controller
This commit is contained in:
parent
216e2f9198
commit
ae7fc056f5
8 changed files with 637 additions and 241 deletions
|
@ -1,185 +0,0 @@
|
|||
import { ApplicationController } from './application_controller';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Mention from '@tiptap/extension-mention';
|
||||
import tippy, { type Instance } from 'tippy.js';
|
||||
import { httpRequest } from '@utils';
|
||||
|
||||
export class AttestationController extends ApplicationController {
|
||||
static values = {
|
||||
tags: Array,
|
||||
url: String
|
||||
};
|
||||
|
||||
static targets = ['editor', 'bold'];
|
||||
|
||||
declare readonly tagsValue: string[];
|
||||
declare readonly urlValue: string;
|
||||
declare editor: Editor;
|
||||
declare editorTarget: HTMLElement;
|
||||
declare boldTarget: HTMLButtonElement;
|
||||
|
||||
connect() {
|
||||
const conf = {
|
||||
element: this.editorTarget,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'fr-input'
|
||||
}
|
||||
},
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'mention'
|
||||
},
|
||||
suggestion: {
|
||||
items: ({ query }) => {
|
||||
return this.tagsValue
|
||||
.filter((item) =>
|
||||
item.toLowerCase().startsWith(query.toLowerCase())
|
||||
)
|
||||
.slice(0, 5);
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let popup: Instance;
|
||||
let div: HTMLElement;
|
||||
let selectedIndex = 0;
|
||||
let items: string[];
|
||||
let command: (props: object) => void;
|
||||
|
||||
const makeList = () => {
|
||||
return items
|
||||
.map((item, i) => {
|
||||
if (i == selectedIndex) {
|
||||
return `<li class='selected'>${item}</li>`;
|
||||
} else {
|
||||
return `<li>${item}</li>`;
|
||||
}
|
||||
})
|
||||
.join('');
|
||||
};
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
items = props.items;
|
||||
command = props.command;
|
||||
|
||||
div = document.createElement('UL');
|
||||
div.innerHTML = makeList();
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup = tippy(document.body, {
|
||||
getReferenceClientRect: () => {
|
||||
const domrect = props.clientRect?.();
|
||||
if (!domrect) {
|
||||
throw new Error('No client rect');
|
||||
}
|
||||
return domrect;
|
||||
},
|
||||
appendTo: () => this.element,
|
||||
content: div,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start'
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
command = props.command;
|
||||
items = props.items;
|
||||
|
||||
div.innerHTML = makeList();
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup.setProps({
|
||||
getReferenceClientRect: () => {
|
||||
const domrect = props.clientRect?.();
|
||||
if (!domrect) {
|
||||
throw new Error('No client rect');
|
||||
}
|
||||
return domrect;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup.hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (props.event.key === 'ArrowDown') {
|
||||
selectedIndex = (selectedIndex + 1) % items.length;
|
||||
div.innerHTML = makeList();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (props.event.key === 'ArrowUp') {
|
||||
selectedIndex =
|
||||
(selectedIndex + items.length - 1) % items.length;
|
||||
div.innerHTML = makeList();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (props.event.key === 'Enter') {
|
||||
const item = items[selectedIndex];
|
||||
|
||||
if (item) {
|
||||
command({ id: item });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup.destroy();
|
||||
div.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
})
|
||||
],
|
||||
content:
|
||||
'<p>La situation de M. <span data-type="mention" data-id="nom"></span> dont la demande de logement social</p>'
|
||||
};
|
||||
|
||||
this.editor = new Editor(conf);
|
||||
|
||||
this.editor.on('transaction', () => {
|
||||
this.boldTarget.disabled = !this.editor
|
||||
.can()
|
||||
.chain()
|
||||
.focus()
|
||||
.toggleBold()
|
||||
.run();
|
||||
|
||||
if (this.editor.isActive('bold')) {
|
||||
this.boldTarget.classList.add('fr-btn--secondary');
|
||||
} else {
|
||||
this.boldTarget.classList.remove('fr-btn--secondary');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bold() {
|
||||
this.editor.chain().focus().toggleBold().run();
|
||||
}
|
||||
|
||||
send() {
|
||||
const json = this.editor.getJSON();
|
||||
httpRequest(this.urlValue, { method: 'put', json }).json();
|
||||
}
|
||||
}
|
96
app/javascript/controllers/tiptap_controller.ts
Normal file
96
app/javascript/controllers/tiptap_controller.ts
Normal file
|
@ -0,0 +1,96 @@
|
|||
import { Editor, type JSONContent } from '@tiptap/core';
|
||||
import { isButtonElement, isHTMLElement } from '@coldwired/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ApplicationController } from './application_controller';
|
||||
import { getAction } from '../shared/tiptap/actions';
|
||||
import { tagSchema, type TagSchema } from '../shared/tiptap/tags';
|
||||
import { createEditor } from '../shared/tiptap/editor';
|
||||
|
||||
export class TiptapController extends ApplicationController {
|
||||
static targets = ['editor', 'input', 'button', 'tag'];
|
||||
|
||||
declare editorTarget: Element;
|
||||
declare inputTarget: HTMLInputElement;
|
||||
declare buttonTargets: HTMLButtonElement[];
|
||||
declare tagTargets: HTMLElement[];
|
||||
|
||||
#initializing = true;
|
||||
#editor?: Editor;
|
||||
|
||||
connect(): void {
|
||||
this.#editor = createEditor({
|
||||
editorElement: this.editorTarget,
|
||||
content: this.content,
|
||||
tags: this.tags,
|
||||
buttons: this.menuButtons,
|
||||
onChange: ({ editor }) => {
|
||||
for (const button of this.buttonTargets) {
|
||||
const action = getAction(editor, button);
|
||||
button.classList.toggle('fr-btn--secondary', !action.isActive());
|
||||
button.disabled = action.isDisabled();
|
||||
}
|
||||
|
||||
const previousValue = this.inputTarget.value;
|
||||
const value = JSON.stringify(editor.getJSON());
|
||||
this.inputTarget.value = value;
|
||||
|
||||
// Dispatch input event only if the value has changed and not during initialization
|
||||
if (this.#initializing) {
|
||||
this.#initializing = false;
|
||||
} else if (value != previousValue) {
|
||||
this.dispatch('input', { target: this.inputTarget, prefix: '' });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.#editor?.destroy();
|
||||
}
|
||||
|
||||
menuButton(event: MouseEvent) {
|
||||
if (this.#editor && isButtonElement(event.target)) {
|
||||
getAction(this.#editor, event.target).run();
|
||||
}
|
||||
}
|
||||
|
||||
insertTag(event: MouseEvent) {
|
||||
if (this.#editor && isHTMLElement(event.target)) {
|
||||
const tag = tagSchema.parse(event.target.dataset);
|
||||
this.#editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent({ type: 'mention', attrs: tag })
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
private get content() {
|
||||
const value = this.inputTarget.value;
|
||||
if (value) {
|
||||
return jsonContentSchema.parse(JSON.parse(value));
|
||||
}
|
||||
}
|
||||
|
||||
private get tags(): TagSchema[] {
|
||||
return this.tagTargets.map((tag) => tagSchema.parse(tag.dataset));
|
||||
}
|
||||
|
||||
private get menuButtons() {
|
||||
return this.buttonTargets.map(
|
||||
(menuButton) => menuButton.dataset.tiptapAction as string
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const jsonContentSchema: z.ZodType<JSONContent> = z.object({
|
||||
type: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
attrs: z.record(z.any()).optional(),
|
||||
marks: z
|
||||
.object({ type: z.string(), attrs: z.record(z.any()).optional() })
|
||||
.array()
|
||||
.optional(),
|
||||
content: z.lazy(() => z.array(jsonContentSchema).optional())
|
||||
});
|
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();
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
18
package.json
18
package.json
|
@ -20,9 +20,25 @@
|
|||
"@sentry/browser": "7.66.0",
|
||||
"@stimulus/polyfills": "^2.0.0",
|
||||
"@tiptap/core": "^2.1.12",
|
||||
"@tiptap/extension-bold": "^2.1.12",
|
||||
"@tiptap/extension-bullet-list": "^2.1.12",
|
||||
"@tiptap/extension-document": "^2.1.12",
|
||||
"@tiptap/extension-gapcursor": "^2.1.12",
|
||||
"@tiptap/extension-heading": "^2.1.12",
|
||||
"@tiptap/extension-highlight": "^2.1.12",
|
||||
"@tiptap/extension-history": "^2.1.12",
|
||||
"@tiptap/extension-italic": "^2.1.12",
|
||||
"@tiptap/extension-link": "^2.1.12",
|
||||
"@tiptap/extension-list-item": "^2.1.12",
|
||||
"@tiptap/extension-mention": "^2.1.12",
|
||||
"@tiptap/extension-ordered-list": "^2.1.12",
|
||||
"@tiptap/extension-paragraph": "^2.1.12",
|
||||
"@tiptap/extension-strike": "^2.1.12",
|
||||
"@tiptap/extension-text": "^2.1.12",
|
||||
"@tiptap/extension-text-align": "^2.1.12",
|
||||
"@tiptap/extension-typography": "^2.1.12",
|
||||
"@tiptap/extension-underline": "^2.1.12",
|
||||
"@tiptap/pm": "^2.1.12",
|
||||
"@tiptap/starter-kit": "^2.1.12",
|
||||
"@tiptap/suggestion": "^2.1.12",
|
||||
"@tmcw/togeojson": "^5.6.0",
|
||||
"chartkick": "^5.0.1",
|
||||
|
|
87
yarn.lock
87
yarn.lock
|
@ -2286,11 +2286,6 @@
|
|||
resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.1.12.tgz#904fdf147e91b5e60561c76e7563c1b5a32f54ab"
|
||||
integrity sha512-ZGc3xrBJA9KY8kln5AYTj8y+GDrKxi7u95xIl2eccrqTY5CQeRu6HRNM1yT4mAjuSaG9jmazyjGRlQuhyxCKxQ==
|
||||
|
||||
"@tiptap/extension-blockquote@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-2.1.12.tgz#97b43419606acf9bfd93b9f482a1827dcac8c3e9"
|
||||
integrity sha512-Qb3YRlCfugx9pw7VgLTb+jY37OY4aBJeZnqHzx4QThSm13edNYjasokbX0nTwL1Up4NPTcY19JUeHt6fVaVVGg==
|
||||
|
||||
"@tiptap/extension-bold@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-2.1.12.tgz#5dbf41105fc0fbde8adbff629312187fbebc39b0"
|
||||
|
@ -2301,56 +2296,43 @@
|
|||
resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-2.1.12.tgz#7c905a577ce30ef2cb335870a23f9d24fd26f6aa"
|
||||
integrity sha512-vtD8vWtNlmAZX8LYqt2yU9w3mU9rPCiHmbp4hDXJs2kBnI0Ju/qAyXFx6iJ3C3XyuMnMbJdDI9ee0spAvFz7cQ==
|
||||
|
||||
"@tiptap/extension-code-block@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-2.1.12.tgz#20416baef1b5fc839490a8416e97fdcbb5fdf918"
|
||||
integrity sha512-RXtSYCVsnk8D+K80uNZShClfZjvv1EgO42JlXLVGWQdIgaNyuOv/6I/Jdf+ZzhnpsBnHufW+6TJjwP5vJPSPHA==
|
||||
|
||||
"@tiptap/extension-code@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-2.1.12.tgz#86d2eb5f63725af472c5fd858e5a9c7ccae06ef3"
|
||||
integrity sha512-CRiRq5OTC1lFgSx6IMrECqmtb93a0ZZKujEnaRhzWliPBjLIi66va05f/P1vnV6/tHaC3yfXys6dxB5A4J8jxw==
|
||||
|
||||
"@tiptap/extension-document@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.1.12.tgz#e19e4716dfad60cbeb6abaf2f362fed759963529"
|
||||
integrity sha512-0QNfAkCcFlB9O8cUNSwTSIQMV9TmoEhfEaLz/GvbjwEq4skXK3bU+OQX7Ih07waCDVXIGAZ7YAZogbvrn/WbOw==
|
||||
|
||||
"@tiptap/extension-dropcursor@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-2.1.12.tgz#9da0c275291c9d47497d3db41b4d70d96366b4ff"
|
||||
integrity sha512-0tT/q8nL4NBCYPxr9T0Brck+RQbWuczm9nV0bnxgt0IiQXoRHutfPWdS7GA65PTuVRBS/3LOco30fbjFhkfz/A==
|
||||
|
||||
"@tiptap/extension-gapcursor@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-2.1.12.tgz#63844c3abd1a38af915839cf0c097b6d2e5a86fe"
|
||||
integrity sha512-zFYdZCqPgpwoB7whyuwpc8EYLYjUE5QYKb8vICvc+FraBUDM51ujYhFSgJC3rhs8EjI+8GcK8ShLbSMIn49YOQ==
|
||||
|
||||
"@tiptap/extension-hard-break@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.1.12.tgz#54d0c9996e1173594852394975a9356eec98bc9a"
|
||||
integrity sha512-nqKcAYGEOafg9D+2cy1E4gHNGuL12LerVa0eS2SQOb+PT8vSel9OTKU1RyZldsWSQJ5rq/w4uIjmLnrSR2w6Yw==
|
||||
|
||||
"@tiptap/extension-heading@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-2.1.12.tgz#05ae4684d6f29ae611495ab114038e14a5d1dff6"
|
||||
integrity sha512-MoANP3POAP68Ko9YXarfDKLM/kXtscgp6m+xRagPAghRNujVY88nK1qBMZ3JdvTVN6b/ATJhp8UdrZX96TLV2w==
|
||||
|
||||
"@tiptap/extension-highlight@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-highlight/-/extension-highlight-2.1.12.tgz#184efb75238c9cbc6c18d523b735de4329f78ecc"
|
||||
integrity sha512-buen31cYPyiiHA2i0o2i/UcjRTg/42mNDCizGr1OJwvv3AELG3qOFc4Y58WJWIvWNv+1Dr4ZxHA3GNVn0ANWyg==
|
||||
|
||||
"@tiptap/extension-history@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-2.1.12.tgz#03bcb9422e8ea2b82dc45207d1a1b0bc0241b055"
|
||||
integrity sha512-6b7UFVkvPjq3LVoCTrYZAczt5sQrQUaoDWAieVClVZoFLfjga2Fwjcfgcie8IjdPt8YO2hG/sar/c07i9vM0Sg==
|
||||
|
||||
"@tiptap/extension-horizontal-rule@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.1.12.tgz#2191d4ff68ed39381d65971ad8e2aa1be43e6d6b"
|
||||
integrity sha512-RRuoK4KxrXRrZNAjJW5rpaxjiP0FJIaqpi7nFbAua2oHXgsCsG8qbW2Y0WkbIoS8AJsvLZ3fNGsQ8gpdliuq3A==
|
||||
|
||||
"@tiptap/extension-italic@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-2.1.12.tgz#e99480eb77f8b4e5444fc236add8a831d5aa2343"
|
||||
integrity sha512-/XYrW4ZEWyqDvnXVKbgTXItpJOp2ycswk+fJ3vuexyolO6NSs0UuYC6X4f+FbHYL5VuWqVBv7EavGa+tB6sl3A==
|
||||
|
||||
"@tiptap/extension-link@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-2.1.12.tgz#a18f83a0b54342e6274ff9e5a5907ef7f15aa723"
|
||||
integrity sha512-Sti5hhlkCqi5vzdQjU/gbmr8kb578p+u0J4kWS+SSz3BknNThEm/7Id67qdjBTOQbwuN07lHjDaabJL0hSkzGQ==
|
||||
dependencies:
|
||||
linkifyjs "^4.1.0"
|
||||
|
||||
"@tiptap/extension-list-item@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.1.12.tgz#3eb28dc998490a98f14765783770b3cf6587d39e"
|
||||
|
@ -2376,11 +2358,26 @@
|
|||
resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-2.1.12.tgz#2b049aedf2985e9c9e3c3f1cc0b203a574c85bd8"
|
||||
integrity sha512-HlhrzIjYUT8oCH9nYzEL2QTTn8d1ECnVhKvzAe6x41xk31PjLMHTUy8aYjeQEkWZOWZ34tiTmslV1ce6R3Dt8g==
|
||||
|
||||
"@tiptap/extension-text-align@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-text-align/-/extension-text-align-2.1.12.tgz#962dca4d284ce57ed345fd4b94ddb3a97944e0d1"
|
||||
integrity sha512-siMlwrkgVrAxxgmZn8GOc75J7UZi2CVrP9vDHkUPPyKm/fjssYekXwGCEk4Vswii1BbOh2gt+MDsRkeYRGyDlQ==
|
||||
|
||||
"@tiptap/extension-text@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.1.12.tgz#466e3244bdd9b2db2304c0c9a1d51ce59f5327d0"
|
||||
integrity sha512-rCNUd505p/PXwU9Jgxo4ZJv4A3cIBAyAqlx/dtcY6cjztCQuXJhuQILPhjGhBTOLEEL4kW2wQtqzCmb7O8i2jg==
|
||||
|
||||
"@tiptap/extension-typography@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-typography/-/extension-typography-2.1.12.tgz#0bf3ba500b49b4f239205e76dda512bda7d3b4f7"
|
||||
integrity sha512-OFkQHmUbKQwVO0b/NP2MLuuqQIWxw/gHaWQF/atgZf3mG0YDV2x3P/u+RBpKnsIujPZFvoEBRJGnstvEAB7zfQ==
|
||||
|
||||
"@tiptap/extension-underline@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-2.1.12.tgz#abd59c4b6c8434dbadb4ff9bff23eefcc6bc095e"
|
||||
integrity sha512-NwwdhFT8gDD0VUNLQx85yFBhP9a8qg8GPuxlGzAP/lPTV8Ubh3vSeQ5N9k2ZF/vHlEvnugzeVCbmYn7wf8vn1g==
|
||||
|
||||
"@tiptap/pm@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-2.1.12.tgz#88a4b19be0eabb13d42ddd540c19ba1bbe74b322"
|
||||
|
@ -2405,31 +2402,6 @@
|
|||
prosemirror-transform "^1.7.0"
|
||||
prosemirror-view "^1.28.2"
|
||||
|
||||
"@tiptap/starter-kit@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-2.1.12.tgz#2bf28091ed08dc8f7b903ba92925e4ffe06257ea"
|
||||
integrity sha512-+RoP1rWV7rSCit2+3wl2bjvSRiePRJE/7YNKbvH8Faz/+AMO23AFegHoUFynR7U0ouGgYDljGkkj35e0asbSDA==
|
||||
dependencies:
|
||||
"@tiptap/core" "^2.1.12"
|
||||
"@tiptap/extension-blockquote" "^2.1.12"
|
||||
"@tiptap/extension-bold" "^2.1.12"
|
||||
"@tiptap/extension-bullet-list" "^2.1.12"
|
||||
"@tiptap/extension-code" "^2.1.12"
|
||||
"@tiptap/extension-code-block" "^2.1.12"
|
||||
"@tiptap/extension-document" "^2.1.12"
|
||||
"@tiptap/extension-dropcursor" "^2.1.12"
|
||||
"@tiptap/extension-gapcursor" "^2.1.12"
|
||||
"@tiptap/extension-hard-break" "^2.1.12"
|
||||
"@tiptap/extension-heading" "^2.1.12"
|
||||
"@tiptap/extension-history" "^2.1.12"
|
||||
"@tiptap/extension-horizontal-rule" "^2.1.12"
|
||||
"@tiptap/extension-italic" "^2.1.12"
|
||||
"@tiptap/extension-list-item" "^2.1.12"
|
||||
"@tiptap/extension-ordered-list" "^2.1.12"
|
||||
"@tiptap/extension-paragraph" "^2.1.12"
|
||||
"@tiptap/extension-strike" "^2.1.12"
|
||||
"@tiptap/extension-text" "^2.1.12"
|
||||
|
||||
"@tiptap/suggestion@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/suggestion/-/suggestion-2.1.12.tgz#a13782d1e625ec03b3f61b6839ecc95b6b685d3f"
|
||||
|
@ -5213,6 +5185,11 @@ linkify-it@^4.0.1:
|
|||
dependencies:
|
||||
uc.micro "^1.0.1"
|
||||
|
||||
linkifyjs@^4.1.0:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.1.2.tgz#48fadb05ddf5a5f7065510a385a500ca1ac4e65e"
|
||||
integrity sha512-1elJrH8MwUgr77Rgmx4JgB/nBgISYVoGossH6pAfCeHG+07TblTn6RWKx0MKozEMJU6NCFYHRih9M8ZtV3YZ+Q==
|
||||
|
||||
local-pkg@^0.4.3:
|
||||
version "0.4.3"
|
||||
resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.4.3.tgz#0ff361ab3ae7f1c19113d9bb97b98b905dbc4963"
|
||||
|
|
Loading…
Reference in a new issue