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 + "#{children(content, tags)}" + 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 [ + '
    Left
    Right
    ', + '

    Title

    ', '

    Hello world!

    ', '

    Bonjour Paul !

    ', '

    Heading 1

    ', '

    Heading 2

    ', '

    Heading 3

    ', '
    • Item 1

    • Item 2

    ', - '
    1. Item 1

    2. Item 2

    ' + '
    1. Item 1

    2. 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"