Skip to content

Commit 318cff2

Browse files
committed
Footnote improvements
1 parent 0a0caee commit 318cff2

8 files changed

Lines changed: 233 additions & 11 deletions

File tree

resources/css/app.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
@source '../views/**/*.blade.php';
1010
@source '../js/**/*.vue';
1111
@source '../js/components/ui/**/*.ts';
12+
@source '../js/form/components/fields/editor/extensions/Footnotes.ts';
1213

1314
@custom-variant dark (&:is(.dark *));
1415

resources/js/form/components/fields/editor/Editor.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,7 @@
363363
</template>
364364

365365
<div :class="cn(
366-
'flex-1 grid grid-cols-1 overflow-y-auto overflow-x-clip',
366+
'flex-1 grid grid-cols-1 overflow-y-auto scroll-py-4 overflow-x-clip',
367367
isFullscreen
368368
? 'lg:[scrollbar-gutter:stable]'
369369
: ['min-h-20', {
@@ -419,11 +419,12 @@
419419
'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',
420420
'[&_.selection-highlight]:bg-[Highlight] [&_.selection-highlight]:py-0.5',
421421
'[&_.ProseMirror-selectednode]:outline-none! [&:focus_.ProseMirror-selectednode]:ring-1 [&_.ProseMirror-selectednode]:ring-primary',
422-
`[&_.footnote-ref]:before:content-['['] [&_.footnote-ref]:after:content-[']']`,
422+
`[&_.footnotes]:before:content-(--footnote-title) [&_.footnotes]:before:text-xs [&_.footnotes]:before:text-muted-foreground [&_.footnotes]:before:block [&_.footnotes]:before:ml-[-1.75em] [&_.footnotes]:before:mb-2 [&_.footnotes]:-mx-3 [&_.footnotes]:pt-2 [&_.footnotes]:pb-2 [&_.footnotes]:pr-3 [&_.footnotes]:pl-[calc(.75rem+1.75em)] [&_.footnotes>li]:relative [&_.footnotes>li]:pl-[1.25em] [&_.footnotes]:border-t [&_.footnote-ref]:underline [&_.footnote-ref]:underline-offset-4 [&_.footnote-ref]:decoration-foreground/20 [&_.footnote-ref]:hover:decoration-foreground [&_.footnote-ref]:before:content-['['] [&_.footnote-ref]:after:content-[']']`,
423423
{
424424
'content-lg max-w-3xl mx-auto py-6 px-4 sm:px-6 text-base min-h-max': isFullscreen,
425425
},
426426
)"
427+
:style="{ '--footnote-title': `'${__('sharp::form.editor.footnotes.title')}'` }"
427428
role="textbox"
428429
/>
429430
</div>

resources/js/form/components/fields/editor/extensions/Footnotes.ts

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,62 @@
11
import { Footnote, Footnotes as BaseFootnotes, FootnoteReference } from "tiptap-footnotes";
22
import { Plugin, PluginKey } from "@tiptap/pm/state";
3-
import { Extension } from "@tiptap/core";
3+
import { Decoration, DecorationSet } from "@tiptap/pm/view";
4+
import { Extension, mergeAttributes, NodePos } from "@tiptap/core";
45

56
export const Footnotes = Extension.create({
7+
name: 'footnotesExtension',
68
addExtensions() {
79
return [
810
BaseFootnotes,
911
Footnote.extend({
12+
addProseMirrorPlugins() {
13+
const editor = this.editor;
14+
return [
15+
new Plugin({
16+
key: new PluginKey('footnoteDecoration'),
17+
props: {
18+
decorations: (state) => {
19+
const decorations: Decoration[] = [];
20+
state.doc.descendants((node, pos) => {
21+
if (node.type.name === 'footnote') {
22+
const id = node.attrs.id;
23+
const refId = id?.startsWith('fn:') ? id.replace('fn:', '') : id;
24+
decorations.push(
25+
Decoration.widget(pos + 1, () => {
26+
const sup = document.createElement('sup');
27+
const a = document.createElement('a');
28+
a.href = `#fnref:${refId}`;
29+
a.draggable = false;
30+
a.className = 'underline underline-offset-4 decoration-foreground/20 hover:decoration-foreground select-none';
31+
a.textContent = '[^]';
32+
sup.className = 'footnote-backref absolute left-[.125em] top-1.5'
33+
sup.appendChild(a);
34+
return sup;
35+
})
36+
);
37+
}
38+
});
39+
return DecorationSet.create(state.doc, decorations);
40+
},
41+
handleClickOn(view, pos, node, nodePos, event) {
42+
if((event.target as HTMLElement)?.closest('.footnote-backref')) {
43+
event.preventDefault();
44+
const id = (event.target as HTMLElement).closest('[data-id]').getAttribute('data-id');
45+
setTimeout(() => editor.commands.focusFootnoteReference(id));
46+
return true;
47+
}
48+
},
49+
},
50+
}),
51+
];
52+
},
1053
addCommands() {
1154
return {
1255
focusFootnote: (id: string) => ({ editor, chain }) => {
1356
const matchedFootnote = editor.$node("footnote", {
1457
"data-id": id,
1558
});
59+
1660
if (matchedFootnote) {
1761
// sets the text selection to the end of the footnote definition and scroll to it.
1862
chain()
@@ -32,6 +76,35 @@ export const Footnotes = Extension.create({
3276
}),
3377
FootnoteReference
3478
.extend({
79+
addCommands() {
80+
return {
81+
...this.parent(),
82+
focusFootnoteReference: (id: string) => ({ editor, chain }) => {
83+
let matchedFootnoteReference: { from: number } = null;
84+
editor.state.doc.descendants((node, pos) => {
85+
if (node.type.name === 'footnoteReference' && node.attrs['data-id'] === id) {
86+
matchedFootnoteReference = {
87+
from: pos,
88+
}
89+
return false;
90+
}
91+
});
92+
93+
if (matchedFootnoteReference) {
94+
chain()
95+
.focus()
96+
.setTextSelection(
97+
matchedFootnoteReference.from,
98+
)
99+
.run();
100+
101+
editor.view.dom.querySelector(`.footnote-ref[data-id="${id}"]`).scrollIntoView({ block: 'nearest' });
102+
return true;
103+
}
104+
return false;
105+
},
106+
};
107+
},
35108
addProseMirrorPlugins() {
36109
const editor = this.editor;
37110
return [
@@ -41,16 +114,17 @@ export const Footnotes = Extension.create({
41114
props: {
42115
handleDOMEvents: {
43116
click(view, event) {
44-
if(event.target?.closest('.footnote-ref')) {
117+
if((event.target as HTMLElement)?.closest('.footnote-ref')) {
45118
event.preventDefault();
46119
}
47120
}
48121
},
49122
handleClickOn(view, pos, node, nodePos, event) {
50-
// event.stopImmediatePropagation();
51-
const id = node.attrs["data-id"];
52-
console.log(event);
53-
setTimeout(() => editor.commands.focusFootnote(id));
123+
if(node.type.name === 'footnoteReference') {
124+
const id = node.attrs["data-id"];
125+
setTimeout(() => editor.commands.focusFootnote(id));
126+
return true;
127+
}
54128
},
55129
},
56130
}),
@@ -61,3 +135,16 @@ export const Footnotes = Extension.create({
61135
];
62136
}
63137
})
138+
139+
declare module "@tiptap/core" {
140+
interface Commands<ReturnType> {
141+
extendedFootnoteReference: {
142+
/**
143+
* scrolls to & sets the text selection at the end of the footnote with the given id
144+
* @param id the id of the footote (i.e. the `data-id` attribute value of the footnote)
145+
* @example editor.commands.focusFootnote("a43956c1-1ab8-462f-96e4-be3a4b27fd50")
146+
*/
147+
focusFootnoteReference: (id: string) => ReturnType;
148+
};
149+
}
150+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { Extension } from '@tiptap/core'
2+
import type { Node, NodeType } from '@tiptap/pm/model'
3+
import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state'
4+
import { Footnotes } from "tiptap-footnotes";
5+
6+
export const skipTrailingNodeMeta = 'skipTrailingNode'
7+
8+
function nodeEqualsType({
9+
types,
10+
node,
11+
}: {
12+
types: NodeType | NodeType[]
13+
node: Node | null | undefined
14+
}) {
15+
return (node && Array.isArray(types) && types.includes(node.type)) || node?.type === types
16+
}
17+
18+
// New function that handles the footnote at the end
19+
function getLastNode(doc: Node) {
20+
const { lastChild } = doc
21+
if (lastChild?.type.name === Footnotes.name) {
22+
return {
23+
node: doc.content.child(doc.childCount - 2),
24+
pos: doc.content.size - lastChild.nodeSize,
25+
}
26+
}
27+
return {
28+
node: lastChild,
29+
pos: doc.content.size,
30+
}
31+
}
32+
33+
/**
34+
* Extension based on:
35+
* - https://github.com/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/TrailingNode.js
36+
* - https://github.com/remirror/remirror/blob/e0f1bec4a1e8073ce8f5500d62193e52321155b9/packages/prosemirror-trailing-node/src/trailing-node-plugin.ts
37+
*/
38+
39+
export interface TrailingNodeOptions {
40+
/**
41+
* The node type that should be inserted at the end of the document.
42+
* @note the node will always be added to the `notAfter` lists to
43+
* prevent an infinite loop.
44+
* @default undefined
45+
*/
46+
node?: string
47+
/**
48+
* The node types after which the trailing node should not be inserted.
49+
* @default ['paragraph']
50+
*/
51+
notAfter?: string | string[]
52+
}
53+
54+
/**
55+
* This extension allows you to add an extra node at the end of the document.
56+
* @see https://www.tiptap.dev/api/extensions/trailing-node
57+
*/
58+
export const TrailingNode = Extension.create<TrailingNodeOptions>({
59+
name: 'trailingNode',
60+
61+
addOptions() {
62+
return {
63+
node: undefined,
64+
notAfter: [],
65+
}
66+
},
67+
68+
addProseMirrorPlugins() {
69+
const plugin = new PluginKey(this.name)
70+
const defaultNode =
71+
this.options.node ||
72+
this.editor.schema.topNodeType.contentMatch.defaultType?.name ||
73+
'paragraph'
74+
75+
const disabledNodes = Object.entries(this.editor.schema.nodes)
76+
.map(([, value]) => value)
77+
.filter(node => (this.options.notAfter || []).concat(defaultNode).includes(node.name))
78+
79+
return [
80+
new Plugin({
81+
key: plugin,
82+
appendTransaction: (transactions, __, state) => {
83+
const { doc, tr, schema } = state
84+
const shouldInsertNodeAtEnd = plugin.getState(state)
85+
const { pos: endPosition } = getLastNode(doc)
86+
const type = schema.nodes[defaultNode]
87+
88+
if (transactions.some(transaction => transaction.getMeta(skipTrailingNodeMeta))) {
89+
return
90+
}
91+
92+
if (!shouldInsertNodeAtEnd) {
93+
return
94+
}
95+
96+
return tr.insert(endPosition, type.create())
97+
},
98+
state: {
99+
init: (_, state) => {
100+
const { node: lastNode } = getLastNode(state.tr.doc)
101+
console.log('lastNode', lastNode)
102+
103+
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
104+
},
105+
apply: (tr, value) => {
106+
if (!tr.docChanged) {
107+
return value
108+
}
109+
110+
// Ignore transactions from UniqueID extension to prevent infinite loops
111+
// when UniqueID adds IDs to newly inserted trailing nodes
112+
if (tr.getMeta('__uniqueIDTransaction')) {
113+
return value
114+
}
115+
116+
const { node: lastNode } = getLastNode(tr.doc)
117+
118+
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
119+
},
120+
},
121+
}),
122+
]
123+
},
124+
})

resources/js/form/components/fields/editor/extensions/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Text } from '@tiptap/extension-text';
44
import { Paragraph } from '@tiptap/extension-paragraph';
55
import { Blockquote } from '@tiptap/extension-blockquote';
66
import { Bold } from '@tiptap/extension-bold';
7-
import { UndoRedo, Placeholder, Gapcursor, Dropcursor, CharacterCount, TrailingNode } from '@tiptap/extensions';
7+
import { UndoRedo, Placeholder, Gapcursor, Dropcursor, CharacterCount } from '@tiptap/extensions';
88
import { BulletList } from '@tiptap/extension-bullet-list';
99
import { Code } from '@tiptap/extension-code';
1010
import { Heading } from '@tiptap/extension-heading';
@@ -29,6 +29,7 @@ import { Small } from './Small';
2929
import { FormEditorFieldData, FormEditorToolbarButton } from "@/types";
3030
import { CodeBlock } from "@/form/components/fields/editor/extensions/CodeBlock";
3131
import { Footnotes } from "@/form/components/fields/editor/extensions/Footnotes";
32+
import { TrailingNode } from "@/form/components/fields/editor/extensions/TrailingNode";
3233

3334
export function getExtensions(field: FormEditorFieldData) {
3435
const toolbarHas = (buttonName: FormEditorToolbarButton | FormEditorToolbarButton[]) =>
@@ -43,6 +44,7 @@ export function getExtensions(field: FormEditorFieldData) {
4344
toolbarHas('bold') && Bold,
4445
toolbarHas('bullet-list') && BulletList,
4546
Extension.create({
47+
name: 'characterCountExtension',
4648
addExtensions() { // use addExtension to ensure unique state
4749
return [
4850
CharacterCount.configure(),
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
/**
22
* trim empty paragraphs at the end
33
*/
4-
export function trimHTML(content, { inline }) {
4+
export function trimHTML(content: string, { inline }: { inline: boolean }) {
55
if(inline) {
66
return content.replace(/<\/?p>/g, '');
77
}
8-
return content.replace(/(<p>\s*<\/p>)+$/, '');
8+
return content
9+
.replace(/(?:<p>\s*<\/p>)+(<ol class="footnotes">.+?<\/ol>)?$/, '$1');
910
}

resources/lang/en/form.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@
132132
'editor.toolbar.upload_image.title' => 'Insert image',
133133
'editor.toolbar.horizontal_rule.title' => 'Horizontal rule',
134134
'editor.toolbar.fullscreen.title' => 'Full screen',
135+
'editor.toolbar.footnote.title' => 'Footnote',
135136
'editor.toolbar.undo.title' => 'Undo',
136137
'editor.toolbar.redo.title' => 'Redo',
138+
139+
'editor.footnotes.title' => 'Footnotes',
137140
];

resources/lang/fr/form.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,9 @@
135135
'editor.toolbar.upload_image.title' => 'Insérer une image',
136136
'editor.toolbar.horizontal_rule.title' => 'Séparateur',
137137
'editor.toolbar.fullscreen.title' => 'Plein écran',
138+
'editor.toolbar.footnote.title' => 'Note de bas de page',
138139
'editor.toolbar.undo.title' => 'Annuler',
139140
'editor.toolbar.redo.title' => 'Rétablir',
141+
142+
'editor.footnotes.title' => 'Notes de bas de page',
140143
];

0 commit comments

Comments
 (0)