diff --git a/app/assets/stylesheets/attestation_template_2_edit.scss b/app/assets/stylesheets/attestation_template_2_edit.scss
index 2ac705fe1..0fccd26b1 100644
--- a/app/assets/stylesheets/attestation_template_2_edit.scss
+++ b/app/assets/stylesheets/attestation_template_2_edit.scss
@@ -1,16 +1,7 @@
#attestation-edit {
- .mention {
- border: 1px solid var(--text-default-grey);
- padding: 8px;
- }
-
- .selected {
- border: 1px solid #000000;
- }
-
.tiptap {
padding: 8px;
-
- min-height: 300px;
+ overflow-y: scroll;
+ min-height: 400px;
}
}
diff --git a/app/assets/stylesheets/flex.scss b/app/assets/stylesheets/flex.scss
index eb54952c8..484fbda11 100644
--- a/app/assets/stylesheets/flex.scss
+++ b/app/assets/stylesheets/flex.scss
@@ -60,6 +60,10 @@
flex-shrink: 0;
}
+.flex-gap-1 {
+ gap: $default-spacer;
+}
+
.flex-gap-2 {
gap: 2 * $default-spacer;
}
diff --git a/app/controllers/administrateurs/attestation_template_v2s_controller.rb b/app/controllers/administrateurs/attestation_template_v2s_controller.rb
index 685ddde17..df0519d95 100644
--- a/app/controllers/administrateurs/attestation_template_v2s_controller.rb
+++ b/app/controllers/administrateurs/attestation_template_v2s_controller.rb
@@ -24,11 +24,35 @@ module Administrateurs
end
def edit
+ @buttons = [
+ [
+ ['Gras', 'bold', 'bold'],
+ ['Italic', 'italic', 'italic'],
+ ['Souligner', 'underline', 'underline']
+ ],
+ [
+ ['Titre', 'title', 'h-1'],
+ ['Sous titre', 'heading2', 'h-2'],
+ ['Titre de section', 'heading3', 'h-3']
+ ],
+ [
+ ['Liste à puces', 'bulletList', 'list-unordered'],
+ ['Liste numérotée', 'orderedList', 'list-ordered']
+ ],
+ [
+ ['Aligner à gauche', 'left', 'align-left'],
+ ['Aligner au centre', 'center', 'align-center'],
+ ['Aligner à droite', 'right', 'align-right']
+ ],
+ [
+ ['Undo', 'undo', 'arrow-go-back-line'],
+ ['Redo', 'redo', 'arrow-go-forward-line']
+ ]
+ ]
end
def update
- @attestation_template
- .update(json_body: editor_params)
+ @attestation_template.update!(editor_params)
end
private
@@ -42,7 +66,7 @@ module Administrateurs
end
def editor_params
- params.permit(content: [:type, content: [:type, :text, attrs: [:id, :label]]])
+ params.required(:attestation_template).permit(:tiptap_body)
end
end
end
diff --git a/app/javascript/controllers/attestation_controller.ts b/app/javascript/controllers/attestation_controller.ts
deleted file mode 100644
index 29d65cbad..000000000
--- a/app/javascript/controllers/attestation_controller.ts
+++ /dev/null
@@ -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 `
${item}`;
- } else {
- return `${item}`;
- }
- })
- .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:
- 'La situation de M. dont la demande de logement social
'
- };
-
- 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();
- }
-}
diff --git a/app/javascript/controllers/autosubmit_controller.ts b/app/javascript/controllers/autosubmit_controller.ts
index 09e299238..38e94dc3b 100644
--- a/app/javascript/controllers/autosubmit_controller.ts
+++ b/app/javascript/controllers/autosubmit_controller.ts
@@ -43,7 +43,8 @@ export class AutosubmitController extends ApplicationController {
matchInputElement(target, {
date: () => {},
- inputable: () => this.debounce(this.submit, AUTOSUBMIT_DEBOUNCE_DELAY)
+ inputable: () => this.debounce(this.submit, AUTOSUBMIT_DEBOUNCE_DELAY),
+ hidden: () => this.debounce(this.submit, AUTOSUBMIT_DEBOUNCE_DELAY)
});
}
diff --git a/app/javascript/controllers/tiptap_controller.ts b/app/javascript/controllers/tiptap_controller.ts
new file mode 100644
index 000000000..b93881567
--- /dev/null
+++ b/app/javascript/controllers/tiptap_controller.ts
@@ -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 = 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())
+});
diff --git a/app/javascript/shared/tiptap/actions.ts b/app/javascript/shared/tiptap/actions.ts
new file mode 100644
index 000000000..9603cfd44
--- /dev/null
+++ b/app/javascript/shared/tiptap/actions.ts
@@ -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 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]);
diff --git a/app/javascript/shared/tiptap/editor.ts b/app/javascript/shared/tiptap/editor.ts
new file mode 100644
index 000000000..26d3ce662
--- /dev/null
+++ b/app/javascript/shared/tiptap/editor.ts
@@ -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 {
+ 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
+ ]
+ };
+}
diff --git a/app/javascript/shared/tiptap/nodes.ts b/app/javascript/shared/tiptap/nodes.ts
new file mode 100644
index 000000000..cca2c96b0
--- /dev/null
+++ b/app/javascript/shared/tiptap/nodes.ts
@@ -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];
+ }
+});
diff --git a/app/javascript/shared/tiptap/tags.ts b/app/javascript/shared/tiptap/tags.ts
new file mode 100644
index 000000000..e3afbdba3
--- /dev/null
+++ b/app/javascript/shared/tiptap/tags.ts
@@ -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;
+
+class SuggestionMenu {
+ #selectedIndex = 0;
+ #props: SuggestionProps;
+
+ #element?: Element;
+ #popup?: TippyInstance;
+
+ constructor(props: SuggestionProps, 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) {
+ 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 `${item.label}`;
+ })
+ .join('');
+
+ this.#element.classList.add('fr-menu__list');
+ list.innerHTML = html;
+ list.querySelector('.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, '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();
+ }
+ };
+ }
+ };
+}
diff --git a/app/models/attestation_template.rb b/app/models/attestation_template.rb
index 153169d11..b81b5c437 100644
--- a/app/models/attestation_template.rb
+++ b/app/models/attestation_template.rb
@@ -99,6 +99,14 @@ class AttestationTemplate < ApplicationRecord
signature.attached? ? signature.filename : nil
end
+ def tiptap_body
+ json_body&.to_json
+ end
+
+ def tiptap_body=(json)
+ self.json_body = JSON.parse(json)
+ end
+
private
def signature_to_render(groupe_instructeur)
diff --git a/app/services/tiptap_service.rb b/app/services/tiptap_service.rb
index 47d2e6045..2305af492 100644
--- a/app/services/tiptap_service.rb
+++ b/app/services/tiptap_service.rb
@@ -14,8 +14,16 @@ class TiptapService
def node_to_html(node, tags)
case node
+ in type: 'header', content:
+ "#{children(content, tags)}"
+ in type: 'footer', content:, **rest
+ ""
+ in type: 'headerColumn', content:, **rest
+ "#{children(content, tags)}
"
in type: 'paragraph', content:, **rest
"#{children(content, tags)}
"
+ in type: 'title', content:, **rest
+ "#{children(content, tags)}
"
in type: 'heading', attrs: { level:, **attrs }, content:
"#{children(content, tags)}"
in type: 'bulletList', content:
diff --git a/app/views/administrateurs/attestation_template_v2s/edit.html.haml b/app/views/administrateurs/attestation_template_v2s/edit.html.haml
index 592c89661..c8158ee53 100644
--- a/app/views/administrateurs/attestation_template_v2s/edit.html.haml
+++ b/app/views/administrateurs/attestation_template_v2s/edit.html.haml
@@ -1,10 +1,16 @@
-#attestation-edit.fr-container.mt-2{
- data: {
- controller: 'attestation',
- attestation_tags_value: @attestation_template.tags.map { _1[:libelle] },
- attestation_url_value: admin_procedure_attestation_template_v2_path(@procedure)} }
+#attestation-edit.fr-container.mt-2{ data: { controller: 'tiptap' } }
+ = form_for @attestation_template, url: admin_procedure_attestation_template_v2_path(@procedure), data: { turbo: 'true', controller: 'autosubmit' } do |form|
+ .flex.flex-gap-2
+ - @buttons.each do |buttons|
+ .flex.flex-gap-1
+ - buttons.each do |(label, action, icon)|
+ %button.fr-btn.fr-btn--secondary.fr-btn--sm{ type: 'button', title: label, class: "fr-icon-#{icon}", data: { action: 'click->tiptap#menuButton', tiptap_target: 'button', tiptap_action: action } }
+ = label
- %button.fr-btn{ data: { action: 'click->attestation#bold', attestation_target: 'bold' } } Gras
- .editor.mt-2{ data: { attestation_target: 'editor' } }
+ .editor.mt-2{ data: { tiptap_target: 'editor' } }
+ = form.hidden_field :tiptap_body, data: { tiptap_target: 'input' }
- %button.fr-btn.mt-2{ data: { action: 'click->attestation#send' } } Envoyer
+ %ul.mt-2.flex.wrap.flex-gap-1
+ - @attestation_template.tags.each do |tag|
+ %li.fr-badge.fr-badge--sm{ role: 'button', title: tag[:description], data: { action: 'click->tiptap#insertTag', tiptap_target: 'tag', tag_id: tag[:id], tag_label: tag[:libelle] } }
+ = tag[:libelle]
diff --git a/package.json b/package.json
index ae6ce1c85..434cfa85d 100644
--- a/package.json
+++ b/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",
diff --git a/spec/services/tiptap_service_spec.rb b/spec/services/tiptap_service_spec.rb
index 37cdd6d0c..ebd95cac5 100644
--- a/spec/services/tiptap_service_spec.rb
+++ b/spec/services/tiptap_service_spec.rb
@@ -4,6 +4,23 @@ RSpec.describe TiptapService do
{
type: 'doc',
content: [
+ {
+ type: 'header',
+ content: [
+ {
+ type: 'headerColumn',
+ content: [{ type: 'text', text: 'Left' }]
+ },
+ {
+ type: 'headerColumn',
+ content: [{ type: 'text', text: 'Right' }]
+ }
+ ]
+ },
+ {
+ type: 'title',
+ content: [{ type: 'text', text: 'Title' }]
+ },
{
type: 'paragraph',
attrs: { textAlign: 'right' },
@@ -118,6 +135,10 @@ RSpec.describe TiptapService do
]
}
]
+ },
+ {
+ type: 'footer',
+ content: [{ type: 'text', text: 'Footer' }]
}
]
}
@@ -125,13 +146,16 @@ RSpec.describe TiptapService do
let(:tags) { { 'name' => 'Paul' } }
let(:html) do
[
+ '',
+ 'Title
',
'Hello world!
',
'Bonjour Paul !
',
'Heading 1
',
'Heading 2
',
'Heading 3
',
'',
- 'Item 1
Item 2
'
+ 'Item 1
Item 2
',
+ ''
].join
end
diff --git a/yarn.lock b/yarn.lock
index 56a299100..6751cdf1c 100644
--- a/yarn.lock
+++ b/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"