From 57f980dd666d223afca3561f991ace124ba5ee68 Mon Sep 17 00:00:00 2001 From: antoine Date: Thu, 9 Jul 2026 19:42:04 +0200 Subject: [PATCH 1/3] wip footnote --- demo/app/Sharp/Posts/PostForm.php | 3 +- package-lock.json | 34 +++++++++++++++---- package.json | 3 +- .../fields/editor/extensions/index.ts | 15 ++++++-- .../fields/editor/toolbar/config.ts | 7 ++++ resources/js/types/generated.d.ts | 1 + src/Enums/FormEditorToolbarButton.php | 1 + src/Form/Fields/SharpFormEditorField.php | 1 + 8 files changed, 53 insertions(+), 12 deletions(-) diff --git a/demo/app/Sharp/Posts/PostForm.php b/demo/app/Sharp/Posts/PostForm.php index 98491435a..a543ff5a4 100644 --- a/demo/app/Sharp/Posts/PostForm.php +++ b/demo/app/Sharp/Posts/PostForm.php @@ -59,6 +59,7 @@ public function buildFormFields(FieldsContainer $formFields): void SharpFormEditorField::IFRAME, SharpFormEditorField::UPLOAD, SharpFormEditorField::CODE_BLOCK, + SharpFormEditorField::FOOTNOTE, AuthorEmbed::class, ]) ->allowEmbeds([ @@ -79,7 +80,7 @@ public function buildFormFields(FieldsContainer $formFields): void ]) ->allowFullscreen() ->setMaxLength(2000) - ->setHeight(300, 0) + ->setHeight(400) ) ->addField( SharpFormTagsField::make('categories', Category::pluck('name', 'id')->toArray()) diff --git a/package-lock.json b/package-lock.json index 5fd8c5597..5ac878ab5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,12 +50,13 @@ "flexsearch": "^0.8.205", "leaflet": "^1.9.4", "lodash": "^4.17.21", - "lucide-vue-next": "^0.542.0", + "lucide-vue-next": "^1.0.0", "qs": "^6.6.0", "reka-ui": "^2.6.1", "sortablejs": "^1.15.2", "tailwind-merge": "^3.3.0", "text-clipper": "^1.3.0", + "tiptap-footnotes": "^3.0.1", "tiptap-markdown": "^0.9.0", "vue": "^3.5.12", "vue-sonner": "^1.1.2", @@ -11450,9 +11451,10 @@ } }, "node_modules/lucide-vue-next": { - "version": "0.542.0", - "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.542.0.tgz", - "integrity": "sha512-cJfyhFoneDgYTouHwUJEutXaCW5EQuRrBsvfELudWnMiwfqvcEtpZTFZLdZ5Nrqow+znzn+Iyhu3KeYIfa3mEg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz", + "integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==", + "deprecated": "Package deprecated. Please use @lucide/vue instead.", "license": "ISC", "peerDependencies": { "vue": ">=3.0.1" @@ -15294,6 +15296,18 @@ "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", "license": "ISC" }, + "node_modules/tiptap-footnotes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/tiptap-footnotes/-/tiptap-footnotes-3.0.1.tgz", + "integrity": "sha512-JeQav5spy5/gEDHnwbkJrzPB46Lvn9SxefhKCcQKsUopzzqYtjaA2oTO+QxVixRJ88wLAa5KexaCIzvY6hMiSQ==", + "license": "MIT", + "peerDependencies": { + "@tiptap/core": "^3.0.0", + "@tiptap/extension-list-item": "^3.0.0", + "@tiptap/extension-ordered-list": "^3.0.0", + "@tiptap/pm": "^3.0.0" + } + }, "node_modules/tiptap-markdown": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz", @@ -29031,9 +29045,9 @@ } }, "lucide-vue-next": { - "version": "0.542.0", - "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.542.0.tgz", - "integrity": "sha512-cJfyhFoneDgYTouHwUJEutXaCW5EQuRrBsvfELudWnMiwfqvcEtpZTFZLdZ5Nrqow+znzn+Iyhu3KeYIfa3mEg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz", + "integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==", "requires": {} }, "magic-string": { @@ -34828,6 +34842,12 @@ "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==" }, + "tiptap-footnotes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/tiptap-footnotes/-/tiptap-footnotes-3.0.1.tgz", + "integrity": "sha512-JeQav5spy5/gEDHnwbkJrzPB46Lvn9SxefhKCcQKsUopzzqYtjaA2oTO+QxVixRJ88wLAa5KexaCIzvY6hMiSQ==", + "requires": {} + }, "tiptap-markdown": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz", diff --git a/package.json b/package.json index d096c1ba4..bea7f129b 100644 --- a/package.json +++ b/package.json @@ -78,12 +78,13 @@ "flexsearch": "^0.8.205", "leaflet": "^1.9.4", "lodash": "^4.17.21", - "lucide-vue-next": "^0.542.0", + "lucide-vue-next": "^1.0.0", "qs": "^6.6.0", "reka-ui": "^2.6.1", "sortablejs": "^1.15.2", "tailwind-merge": "^3.3.0", "text-clipper": "^1.3.0", + "tiptap-footnotes": "^3.0.1", "tiptap-markdown": "^0.9.0", "vue": "^3.5.12", "vue-sonner": "^1.1.2", diff --git a/resources/js/form/components/fields/editor/extensions/index.ts b/resources/js/form/components/fields/editor/extensions/index.ts index 89b6aac7e..619027b5b 100644 --- a/resources/js/form/components/fields/editor/extensions/index.ts +++ b/resources/js/form/components/fields/editor/extensions/index.ts @@ -28,6 +28,7 @@ import { Clipboard } from './Clipboard'; import { Small } from './Small'; import { FormEditorFieldData, FormEditorToolbarButton } from "@/types"; import { CodeBlock } from "@/form/components/fields/editor/extensions/CodeBlock"; +import { Footnotes, FootnoteReference, Footnote } from "tiptap-footnotes"; export function getExtensions(field: FormEditorFieldData) { const toolbarHas = (buttonName: FormEditorToolbarButton | FormEditorToolbarButton[]) => @@ -54,11 +55,19 @@ export function getExtensions(field: FormEditorFieldData) { toolbarHas('code') && Code, toolbarHas('code-block') && CodeBlock, Document.extend({ - content: field.uploads || Object.keys(field.embeds ?? {}).length - ? '(block | embed)+' - : 'block+', + content: [ + field.uploads || Object.keys(field.embeds ?? {}).length + ? '(block | embed)+' + : 'block+', + toolbarHas('footnote') && 'footnotes?', + ].filter(Boolean).join(' '), }), Dropcursor, + toolbarHas('footnote') && [ + Footnote, + Footnotes, + FootnoteReference, + ], Gapcursor, HardBreak.extend({ addKeyboardShortcuts() { diff --git a/resources/js/form/components/fields/editor/toolbar/config.ts b/resources/js/form/components/fields/editor/toolbar/config.ts index ee212a5a3..c5ff8f043 100644 --- a/resources/js/form/components/fields/editor/toolbar/config.ts +++ b/resources/js/form/components/fields/editor/toolbar/config.ts @@ -16,6 +16,7 @@ import { ImageIcon, Paperclip, Minus, SquarePlay, CodeXml, FileCode, Undo, Redo, TextQuote, Superscript, + StickyNote, } from "lucide-vue-next"; import { Component } from "vue"; @@ -135,6 +136,12 @@ export const buttons: { [key in Exclude __('sharp::form.editor.toolbar.superscript.title'), }, + 'footnote': { + command: editor => editor.chain().focus().addFootnote().run(), + isActive: editor => false, + icon: StickyNote, + label: () => __('sharp::form.editor.toolbar.footnote.title'), + }, 'undo': { command: editor => editor.chain().undo().run(), icon: Undo, diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts index 35bb75464..fbaff8903 100644 --- a/resources/js/types/generated.d.ts +++ b/resources/js/types/generated.d.ts @@ -449,6 +449,7 @@ export type FormEditorToolbarButton = | "html" | "code-block" | "superscript" + | "footnote" | "undo" | "redo" | "|"; diff --git a/src/Enums/FormEditorToolbarButton.php b/src/Enums/FormEditorToolbarButton.php index d1275666f..d51208fb6 100644 --- a/src/Enums/FormEditorToolbarButton.php +++ b/src/Enums/FormEditorToolbarButton.php @@ -27,6 +27,7 @@ enum FormEditorToolbarButton: string case Html = 'html'; case CodeBlock = 'code-block'; case Superscript = 'superscript'; + case Footnote = 'footnote'; case Undo = 'undo'; case Redo = 'redo'; diff --git a/src/Form/Fields/SharpFormEditorField.php b/src/Form/Fields/SharpFormEditorField.php index ee8dda064..d455fab67 100644 --- a/src/Form/Fields/SharpFormEditorField.php +++ b/src/Form/Fields/SharpFormEditorField.php @@ -51,6 +51,7 @@ class SharpFormEditorField extends SharpFormField implements IsSharpFieldWithEmb const RAW_HTML = FormEditorToolbarButton::Html; const CODE_BLOCK = FormEditorToolbarButton::CodeBlock; const SUP = FormEditorToolbarButton::Superscript; + const FOOTNOTE = FormEditorToolbarButton::Footnote; const UNDO = FormEditorToolbarButton::Undo; const REDO = FormEditorToolbarButton::Redo; From 0a0caee71b6f94f19a8edd384a9685b0b124bfcb Mon Sep 17 00:00:00 2001 From: antoine Date: Fri, 10 Jul 2026 12:30:34 +0200 Subject: [PATCH 2/3] wip footnotes --- .../form/components/fields/editor/Editor.vue | 1 + .../fields/editor/extensions/Footnotes.ts | 63 +++++++++++++++++++ .../fields/editor/extensions/index.ts | 8 +-- .../fields/editor/toolbar/config.ts | 4 +- resources/views/app.blade.php | 4 +- .../Sanitization/FormatsSanitizedValue.php | 3 +- 6 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 resources/js/form/components/fields/editor/extensions/Footnotes.ts diff --git a/resources/js/form/components/fields/editor/Editor.vue b/resources/js/form/components/fields/editor/Editor.vue index 8598db340..2ebecc966 100644 --- a/resources/js/form/components/fields/editor/Editor.vue +++ b/resources/js/form/components/fields/editor/Editor.vue @@ -419,6 +419,7 @@ 'group/editor content w-full rounded-b-md focus:outline-none px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', '[&_.selection-highlight]:bg-[Highlight] [&_.selection-highlight]:py-0.5', '[&_.ProseMirror-selectednode]:outline-none! [&:focus_.ProseMirror-selectednode]:ring-1 [&_.ProseMirror-selectednode]:ring-primary', + `[&_.footnote-ref]:before:content-['['] [&_.footnote-ref]:after:content-[']']`, { 'content-lg max-w-3xl mx-auto py-6 px-4 sm:px-6 text-base min-h-max': isFullscreen, }, diff --git a/resources/js/form/components/fields/editor/extensions/Footnotes.ts b/resources/js/form/components/fields/editor/extensions/Footnotes.ts new file mode 100644 index 000000000..5c8349b65 --- /dev/null +++ b/resources/js/form/components/fields/editor/extensions/Footnotes.ts @@ -0,0 +1,63 @@ +import { Footnote, Footnotes as BaseFootnotes, FootnoteReference } from "tiptap-footnotes"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Extension } from "@tiptap/core"; + +export const Footnotes = Extension.create({ + addExtensions() { + return [ + BaseFootnotes, + Footnote.extend({ + addCommands() { + return { + focusFootnote: (id: string) => ({ editor, chain }) => { + const matchedFootnote = editor.$node("footnote", { + "data-id": id, + }); + if (matchedFootnote) { + // sets the text selection to the end of the footnote definition and scroll to it. + chain() + .focus() + .setTextSelection( + matchedFootnote.from + matchedFootnote.content.size + ) + .run(); + + matchedFootnote.element.scrollIntoView({ block: 'end' }); + return true; + } + return false; + }, + }; + }, + }), + FootnoteReference + .extend({ + addProseMirrorPlugins() { + const editor = this.editor; + return [ + new Plugin({ + key: new PluginKey("customFootnoteRefClick"), + + props: { + handleDOMEvents: { + click(view, event) { + if(event.target?.closest('.footnote-ref')) { + event.preventDefault(); + } + } + }, + handleClickOn(view, pos, node, nodePos, event) { + // event.stopImmediatePropagation(); + const id = node.attrs["data-id"]; + console.log(event); + setTimeout(() => editor.commands.focusFootnote(id)); + }, + }, + }), + ...this.parent(), + ] + }, + }), + ]; + } +}) diff --git a/resources/js/form/components/fields/editor/extensions/index.ts b/resources/js/form/components/fields/editor/extensions/index.ts index 619027b5b..7d9ea63e2 100644 --- a/resources/js/form/components/fields/editor/extensions/index.ts +++ b/resources/js/form/components/fields/editor/extensions/index.ts @@ -28,7 +28,7 @@ import { Clipboard } from './Clipboard'; import { Small } from './Small'; import { FormEditorFieldData, FormEditorToolbarButton } from "@/types"; import { CodeBlock } from "@/form/components/fields/editor/extensions/CodeBlock"; -import { Footnotes, FootnoteReference, Footnote } from "tiptap-footnotes"; +import { Footnotes } from "@/form/components/fields/editor/extensions/Footnotes"; export function getExtensions(field: FormEditorFieldData) { const toolbarHas = (buttonName: FormEditorToolbarButton | FormEditorToolbarButton[]) => @@ -63,11 +63,7 @@ export function getExtensions(field: FormEditorFieldData) { ].filter(Boolean).join(' '), }), Dropcursor, - toolbarHas('footnote') && [ - Footnote, - Footnotes, - FootnoteReference, - ], + toolbarHas('footnote') && Footnotes, Gapcursor, HardBreak.extend({ addKeyboardShortcuts() { diff --git a/resources/js/form/components/fields/editor/toolbar/config.ts b/resources/js/form/components/fields/editor/toolbar/config.ts index c5ff8f043..ea3443f5d 100644 --- a/resources/js/form/components/fields/editor/toolbar/config.ts +++ b/resources/js/form/components/fields/editor/toolbar/config.ts @@ -16,7 +16,7 @@ import { ImageIcon, Paperclip, Minus, SquarePlay, CodeXml, FileCode, Undo, Redo, TextQuote, Superscript, - StickyNote, + SquareAsterisk, } from "lucide-vue-next"; import { Component } from "vue"; @@ -139,7 +139,7 @@ export const buttons: { [key in Exclude editor.chain().focus().addFootnote().run(), isActive: editor => false, - icon: StickyNote, + icon: SquareAsterisk, label: () => __('sharp::form.editor.toolbar.footnote.title'), }, 'undo': { diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index dd0ab9c8d..18506f354 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -1,5 +1,7 @@ - + diff --git a/src/Utils/Sanitization/FormatsSanitizedValue.php b/src/Utils/Sanitization/FormatsSanitizedValue.php index 14f7de523..59da41951 100644 --- a/src/Utils/Sanitization/FormatsSanitizedValue.php +++ b/src/Utils/Sanitization/FormatsSanitizedValue.php @@ -41,6 +41,7 @@ private function sanitizer(): HtmlSanitizer { $config = (new HtmlSanitizerConfig()) ->allowSafeElements() + ->allowAttribute('data-id', ['a', 'li']) ->allowElement('iframe', [ 'allow', 'allowfullscreen', @@ -61,7 +62,7 @@ private function sanitizer(): HtmlSanitizer ]) ->allowRelativeLinks() ->allowRelativeMedias() - ->allowElement('div', ['data-encoded-content']) + ->allowElement('div', ['data-encoded-content', 'data-id']) ->allowAttribute('class', allowedElements: '*') ->allowAttribute('style', allowedElements: '*') ->withMaxInputLength(500000); From 318cff23d1bcaf119381f420e34dc1d80cf7857e Mon Sep 17 00:00:00 2001 From: antoine Date: Fri, 10 Jul 2026 18:34:56 +0200 Subject: [PATCH 3/3] Footnote improvements --- resources/css/app.css | 1 + .../form/components/fields/editor/Editor.vue | 5 +- .../fields/editor/extensions/Footnotes.ts | 99 +++++++++++++- .../fields/editor/extensions/TrailingNode.ts | 124 ++++++++++++++++++ .../fields/editor/extensions/index.ts | 4 +- .../components/fields/editor/utils/html.ts | 5 +- resources/lang/en/form.php | 3 + resources/lang/fr/form.php | 3 + 8 files changed, 233 insertions(+), 11 deletions(-) create mode 100644 resources/js/form/components/fields/editor/extensions/TrailingNode.ts diff --git a/resources/css/app.css b/resources/css/app.css index ea810987f..b60d32b97 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -9,6 +9,7 @@ @source '../views/**/*.blade.php'; @source '../js/**/*.vue'; @source '../js/components/ui/**/*.ts'; +@source '../js/form/components/fields/editor/extensions/Footnotes.ts'; @custom-variant dark (&:is(.dark *)); diff --git a/resources/js/form/components/fields/editor/Editor.vue b/resources/js/form/components/fields/editor/Editor.vue index 2ebecc966..cca43336b 100644 --- a/resources/js/form/components/fields/editor/Editor.vue +++ b/resources/js/form/components/fields/editor/Editor.vue @@ -363,7 +363,7 @@
diff --git a/resources/js/form/components/fields/editor/extensions/Footnotes.ts b/resources/js/form/components/fields/editor/extensions/Footnotes.ts index 5c8349b65..9356773f0 100644 --- a/resources/js/form/components/fields/editor/extensions/Footnotes.ts +++ b/resources/js/form/components/fields/editor/extensions/Footnotes.ts @@ -1,18 +1,62 @@ import { Footnote, Footnotes as BaseFootnotes, FootnoteReference } from "tiptap-footnotes"; import { Plugin, PluginKey } from "@tiptap/pm/state"; -import { Extension } from "@tiptap/core"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { Extension, mergeAttributes, NodePos } from "@tiptap/core"; export const Footnotes = Extension.create({ + name: 'footnotesExtension', addExtensions() { return [ BaseFootnotes, Footnote.extend({ + addProseMirrorPlugins() { + const editor = this.editor; + return [ + new Plugin({ + key: new PluginKey('footnoteDecoration'), + props: { + decorations: (state) => { + const decorations: Decoration[] = []; + state.doc.descendants((node, pos) => { + if (node.type.name === 'footnote') { + const id = node.attrs.id; + const refId = id?.startsWith('fn:') ? id.replace('fn:', '') : id; + decorations.push( + Decoration.widget(pos + 1, () => { + const sup = document.createElement('sup'); + const a = document.createElement('a'); + a.href = `#fnref:${refId}`; + a.draggable = false; + a.className = 'underline underline-offset-4 decoration-foreground/20 hover:decoration-foreground select-none'; + a.textContent = '[^]'; + sup.className = 'footnote-backref absolute left-[.125em] top-1.5' + sup.appendChild(a); + return sup; + }) + ); + } + }); + return DecorationSet.create(state.doc, decorations); + }, + handleClickOn(view, pos, node, nodePos, event) { + if((event.target as HTMLElement)?.closest('.footnote-backref')) { + event.preventDefault(); + const id = (event.target as HTMLElement).closest('[data-id]').getAttribute('data-id'); + setTimeout(() => editor.commands.focusFootnoteReference(id)); + return true; + } + }, + }, + }), + ]; + }, addCommands() { return { focusFootnote: (id: string) => ({ editor, chain }) => { const matchedFootnote = editor.$node("footnote", { "data-id": id, }); + if (matchedFootnote) { // sets the text selection to the end of the footnote definition and scroll to it. chain() @@ -32,6 +76,35 @@ export const Footnotes = Extension.create({ }), FootnoteReference .extend({ + addCommands() { + return { + ...this.parent(), + focusFootnoteReference: (id: string) => ({ editor, chain }) => { + let matchedFootnoteReference: { from: number } = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'footnoteReference' && node.attrs['data-id'] === id) { + matchedFootnoteReference = { + from: pos, + } + return false; + } + }); + + if (matchedFootnoteReference) { + chain() + .focus() + .setTextSelection( + matchedFootnoteReference.from, + ) + .run(); + + editor.view.dom.querySelector(`.footnote-ref[data-id="${id}"]`).scrollIntoView({ block: 'nearest' }); + return true; + } + return false; + }, + }; + }, addProseMirrorPlugins() { const editor = this.editor; return [ @@ -41,16 +114,17 @@ export const Footnotes = Extension.create({ props: { handleDOMEvents: { click(view, event) { - if(event.target?.closest('.footnote-ref')) { + if((event.target as HTMLElement)?.closest('.footnote-ref')) { event.preventDefault(); } } }, handleClickOn(view, pos, node, nodePos, event) { - // event.stopImmediatePropagation(); - const id = node.attrs["data-id"]; - console.log(event); - setTimeout(() => editor.commands.focusFootnote(id)); + if(node.type.name === 'footnoteReference') { + const id = node.attrs["data-id"]; + setTimeout(() => editor.commands.focusFootnote(id)); + return true; + } }, }, }), @@ -61,3 +135,16 @@ export const Footnotes = Extension.create({ ]; } }) + +declare module "@tiptap/core" { + interface Commands { + extendedFootnoteReference: { + /** + * scrolls to & sets the text selection at the end of the footnote with the given id + * @param id the id of the footote (i.e. the `data-id` attribute value of the footnote) + * @example editor.commands.focusFootnote("a43956c1-1ab8-462f-96e4-be3a4b27fd50") + */ + focusFootnoteReference: (id: string) => ReturnType; + }; + } +} diff --git a/resources/js/form/components/fields/editor/extensions/TrailingNode.ts b/resources/js/form/components/fields/editor/extensions/TrailingNode.ts new file mode 100644 index 000000000..87ebb52ed --- /dev/null +++ b/resources/js/form/components/fields/editor/extensions/TrailingNode.ts @@ -0,0 +1,124 @@ +import { Extension } from '@tiptap/core' +import type { Node, NodeType } from '@tiptap/pm/model' +import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state' +import { Footnotes } from "tiptap-footnotes"; + +export const skipTrailingNodeMeta = 'skipTrailingNode' + +function nodeEqualsType({ + types, + node, +}: { + types: NodeType | NodeType[] + node: Node | null | undefined +}) { + return (node && Array.isArray(types) && types.includes(node.type)) || node?.type === types +} + +// New function that handles the footnote at the end +function getLastNode(doc: Node) { + const { lastChild } = doc + if (lastChild?.type.name === Footnotes.name) { + return { + node: doc.content.child(doc.childCount - 2), + pos: doc.content.size - lastChild.nodeSize, + } + } + return { + node: lastChild, + pos: doc.content.size, + } +} + +/** + * Extension based on: + * - https://github.com/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/TrailingNode.js + * - https://github.com/remirror/remirror/blob/e0f1bec4a1e8073ce8f5500d62193e52321155b9/packages/prosemirror-trailing-node/src/trailing-node-plugin.ts + */ + +export interface TrailingNodeOptions { + /** + * The node type that should be inserted at the end of the document. + * @note the node will always be added to the `notAfter` lists to + * prevent an infinite loop. + * @default undefined + */ + node?: string + /** + * The node types after which the trailing node should not be inserted. + * @default ['paragraph'] + */ + notAfter?: string | string[] +} + +/** + * This extension allows you to add an extra node at the end of the document. + * @see https://www.tiptap.dev/api/extensions/trailing-node + */ +export const TrailingNode = Extension.create({ + name: 'trailingNode', + + addOptions() { + return { + node: undefined, + notAfter: [], + } + }, + + addProseMirrorPlugins() { + const plugin = new PluginKey(this.name) + const defaultNode = + this.options.node || + this.editor.schema.topNodeType.contentMatch.defaultType?.name || + 'paragraph' + + const disabledNodes = Object.entries(this.editor.schema.nodes) + .map(([, value]) => value) + .filter(node => (this.options.notAfter || []).concat(defaultNode).includes(node.name)) + + return [ + new Plugin({ + key: plugin, + appendTransaction: (transactions, __, state) => { + const { doc, tr, schema } = state + const shouldInsertNodeAtEnd = plugin.getState(state) + const { pos: endPosition } = getLastNode(doc) + const type = schema.nodes[defaultNode] + + if (transactions.some(transaction => transaction.getMeta(skipTrailingNodeMeta))) { + return + } + + if (!shouldInsertNodeAtEnd) { + return + } + + return tr.insert(endPosition, type.create()) + }, + state: { + init: (_, state) => { + const { node: lastNode } = getLastNode(state.tr.doc) + console.log('lastNode', lastNode) + + return !nodeEqualsType({ node: lastNode, types: disabledNodes }) + }, + apply: (tr, value) => { + if (!tr.docChanged) { + return value + } + + // Ignore transactions from UniqueID extension to prevent infinite loops + // when UniqueID adds IDs to newly inserted trailing nodes + if (tr.getMeta('__uniqueIDTransaction')) { + return value + } + + const { node: lastNode } = getLastNode(tr.doc) + + return !nodeEqualsType({ node: lastNode, types: disabledNodes }) + }, + }, + }), + ] + }, +}) diff --git a/resources/js/form/components/fields/editor/extensions/index.ts b/resources/js/form/components/fields/editor/extensions/index.ts index 7d9ea63e2..4a2f6e723 100644 --- a/resources/js/form/components/fields/editor/extensions/index.ts +++ b/resources/js/form/components/fields/editor/extensions/index.ts @@ -4,7 +4,7 @@ import { Text } from '@tiptap/extension-text'; import { Paragraph } from '@tiptap/extension-paragraph'; import { Blockquote } from '@tiptap/extension-blockquote'; import { Bold } from '@tiptap/extension-bold'; -import { UndoRedo, Placeholder, Gapcursor, Dropcursor, CharacterCount, TrailingNode } from '@tiptap/extensions'; +import { UndoRedo, Placeholder, Gapcursor, Dropcursor, CharacterCount } from '@tiptap/extensions'; import { BulletList } from '@tiptap/extension-bullet-list'; import { Code } from '@tiptap/extension-code'; import { Heading } from '@tiptap/extension-heading'; @@ -29,6 +29,7 @@ import { Small } from './Small'; import { FormEditorFieldData, FormEditorToolbarButton } from "@/types"; import { CodeBlock } from "@/form/components/fields/editor/extensions/CodeBlock"; import { Footnotes } from "@/form/components/fields/editor/extensions/Footnotes"; +import { TrailingNode } from "@/form/components/fields/editor/extensions/TrailingNode"; export function getExtensions(field: FormEditorFieldData) { const toolbarHas = (buttonName: FormEditorToolbarButton | FormEditorToolbarButton[]) => @@ -43,6 +44,7 @@ export function getExtensions(field: FormEditorFieldData) { toolbarHas('bold') && Bold, toolbarHas('bullet-list') && BulletList, Extension.create({ + name: 'characterCountExtension', addExtensions() { // use addExtension to ensure unique state return [ CharacterCount.configure(), diff --git a/resources/js/form/components/fields/editor/utils/html.ts b/resources/js/form/components/fields/editor/utils/html.ts index 1d707e21c..0157d4759 100644 --- a/resources/js/form/components/fields/editor/utils/html.ts +++ b/resources/js/form/components/fields/editor/utils/html.ts @@ -1,9 +1,10 @@ /** * trim empty paragraphs at the end */ -export function trimHTML(content, { inline }) { +export function trimHTML(content: string, { inline }: { inline: boolean }) { if(inline) { return content.replace(/<\/?p>/g, ''); } - return content.replace(/(

\s*<\/p>)+$/, ''); + return content + .replace(/(?:

\s*<\/p>)+(

    .+?<\/ol>)?$/, '$1'); } diff --git a/resources/lang/en/form.php b/resources/lang/en/form.php index d453f6dab..b09246c2f 100644 --- a/resources/lang/en/form.php +++ b/resources/lang/en/form.php @@ -132,6 +132,9 @@ 'editor.toolbar.upload_image.title' => 'Insert image', 'editor.toolbar.horizontal_rule.title' => 'Horizontal rule', 'editor.toolbar.fullscreen.title' => 'Full screen', + 'editor.toolbar.footnote.title' => 'Footnote', 'editor.toolbar.undo.title' => 'Undo', 'editor.toolbar.redo.title' => 'Redo', + + 'editor.footnotes.title' => 'Footnotes', ]; diff --git a/resources/lang/fr/form.php b/resources/lang/fr/form.php index 316c9a21d..837badbd3 100644 --- a/resources/lang/fr/form.php +++ b/resources/lang/fr/form.php @@ -135,6 +135,9 @@ 'editor.toolbar.upload_image.title' => 'Insérer une image', 'editor.toolbar.horizontal_rule.title' => 'Séparateur', 'editor.toolbar.fullscreen.title' => 'Plein écran', + 'editor.toolbar.footnote.title' => 'Note de bas de page', 'editor.toolbar.undo.title' => 'Annuler', 'editor.toolbar.redo.title' => 'Rétablir', + + 'editor.footnotes.title' => 'Notes de bas de page', ];