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/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 8598db340..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 new file mode 100644 index 000000000..9356773f0 --- /dev/null +++ b/resources/js/form/components/fields/editor/extensions/Footnotes.ts @@ -0,0 +1,150 @@ +import { Footnote, Footnotes as BaseFootnotes, FootnoteReference } from "tiptap-footnotes"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +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() + .focus() + .setTextSelection( + matchedFootnote.from + matchedFootnote.content.size + ) + .run(); + + matchedFootnote.element.scrollIntoView({ block: 'end' }); + return true; + } + return false; + }, + }; + }, + }), + 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 [ + new Plugin({ + key: new PluginKey("customFootnoteRefClick"), + + props: { + handleDOMEvents: { + click(view, event) { + if((event.target as HTMLElement)?.closest('.footnote-ref')) { + event.preventDefault(); + } + } + }, + handleClickOn(view, pos, node, nodePos, event) { + if(node.type.name === 'footnoteReference') { + const id = node.attrs["data-id"]; + setTimeout(() => editor.commands.focusFootnote(id)); + return true; + } + }, + }, + }), + ...this.parent(), + ] + }, + }), + ]; + } +}) + +declare module "@tiptap/core" { + interface Commands\s*<\/p>)+$/, ''); + return content + .replace(/(?:
\s*<\/p>)+(