Skip to content

Commit 4e299d1

Browse files
authored
Merge pull request #745 from code16/footnotes
Footnotes
2 parents c786013 + ab21a79 commit 4e299d1

19 files changed

Lines changed: 382 additions & 37 deletions

File tree

demo/app/Sharp/Posts/PostForm.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ public function buildFormFields(FieldsContainer $formFields): void
5959
SharpFormEditorField::IFRAME,
6060
SharpFormEditorField::UPLOAD,
6161
SharpFormEditorField::CODE_BLOCK,
62+
SharpFormEditorField::FOOTNOTE,
6263
AuthorEmbed::class,
6364
])
6465
->allowEmbeds([
@@ -79,7 +80,7 @@ public function buildFormFields(FieldsContainer $formFields): void
7980
])
8081
->allowFullscreen()
8182
->setMaxLength(2000)
82-
->setHeight(300, 0)
83+
->setHeight(400)
8384
)
8485
->addField(
8586
SharpFormTagsField::make('categories', Category::pluck('name', 'id')->toArray())

package-lock.json

Lines changed: 27 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,13 @@
7878
"flexsearch": "^0.8.205",
7979
"leaflet": "^1.9.4",
8080
"lodash": "^4.17.21",
81-
"lucide-vue-next": "^0.542.0",
81+
"lucide-vue-next": "^1.0.0",
8282
"qs": "^6.6.0",
8383
"reka-ui": "^2.6.1",
8484
"sortablejs": "^1.15.2",
8585
"tailwind-merge": "^3.3.0",
8686
"text-clipper": "^1.3.0",
87+
"tiptap-footnotes": "^3.0.1",
8788
"tiptap-markdown": "^0.9.0",
8889
"vue": "^3.5.12",
8990
"vue-sonner": "^1.1.2",

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: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
getTextInputReplacementsExtension
6363
} from "@/form/components/fields/editor/extensions/TextInputReplacements";
6464
import CodeBlockDropdown from "@/form/components/fields/editor/toolbar/CodeBlockDropdown.vue";
65+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
6566
6667
const emit = defineEmits<FormFieldEmits<FormEditorFieldData>>();
6768
const props = defineProps<FormFieldProps<FormEditorFieldData>>();
@@ -189,11 +190,6 @@
189190
}
190191
);
191192
192-
declare module '@tiptap/core' {
193-
interface Storage {
194-
scrollYPercentage: number;
195-
}
196-
}
197193
198194
const isFullscreen = ref(false);
199195
const fullscreenPlaceholderHeight = ref(0);
@@ -303,17 +299,29 @@
303299
<Separator orientation="vertical" class="h-4 self-center last:hidden" />
304300
</template>
305301
<template v-else :key="button">
306-
<Toggle
307-
size="sm"
308-
:model-value="buttons[button].isActive(editor)"
309-
:disabled="field.readOnly"
310-
:title="buttons[button].label()"
311-
@click="button === 'upload' || button === 'upload-image'
312-
? uploadModal.open({ locale: props.locale })
313-
: buttons[button].command(editor)"
314-
>
315-
<component :is="buttons[button].icon" class="size-4" />
316-
</Toggle>
302+
<TooltipProvider>
303+
<Tooltip :disabled="!buttons[button].tooltip" :delay-duration="0" disable-hoverable-content>
304+
<TooltipTrigger as-child>
305+
<Toggle
306+
size="sm"
307+
:model-value="buttons[button].isActive(editor)"
308+
:disabled="field.readOnly"
309+
:title="!buttons[button].tooltip ? buttons[button].label() : null"
310+
@click="button === 'upload' || button === 'upload-image'
311+
? uploadModal.open({ locale: props.locale })
312+
: buttons[button].command(editor)"
313+
>
314+
<component :is="buttons[button].icon" class="size-4" />
315+
</Toggle>
316+
</TooltipTrigger>
317+
<template v-if="buttons[button].tooltip">
318+
<TooltipContent side="top" :side-offset="10">
319+
<h4 class="font-medium">{{ buttons[button].label() }}</h4>
320+
<div v-html="buttons[button].tooltip()"></div>
321+
</TooltipContent>
322+
</template>
323+
</Tooltip>
324+
</TooltipProvider>
317325
</template>
318326
</template>
319327
<template v-if="dropdownEmbeds.length > 0">
@@ -363,7 +371,7 @@
363371
</template>
364372

365373
<div :class="cn(
366-
'flex-1 grid grid-cols-1 overflow-y-auto overflow-x-clip',
374+
'flex-1 grid grid-cols-1 overflow-y-auto scroll-py-4 overflow-x-clip',
367375
isFullscreen
368376
? 'lg:[scrollbar-gutter:stable]'
369377
: ['min-h-20', {
@@ -419,10 +427,13 @@
419427
'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',
420428
'[&_.selection-highlight]:bg-[Highlight] [&_.selection-highlight]:py-0.5',
421429
'[&_.ProseMirror-selectednode]:outline-none! [&:focus_.ProseMirror-selectednode]:ring-1 [&_.ProseMirror-selectednode]:ring-primary',
430+
`[&_.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`,
431+
` [&_.footnote-ref]:cursor-pointer [&_.footnote-ref]:underline [&_.footnote-ref]:underline-offset-4 [&_.footnote-ref]:decoration-foreground/20 [&_.footnote-ref]:hover:decoration-foreground [&_.footnote-ref]:p-1 [&_.footnote-ref]:-m-1 [&_.footnote-ref]:before:content-['['] [&_.footnote-ref]:after:content-[']']`,
422432
{
423433
'content-lg max-w-3xl mx-auto py-6 px-4 sm:px-6 text-base min-h-max': isFullscreen,
424434
},
425435
)"
436+
:style="{ '--footnote-title': `'${__('sharp::form.editor.footnotes.title')}'` }"
426437
role="textbox"
427438
/>
428439
</div>

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script setup lang="ts">
2-
import { Editor } from "@tiptap/vue-3";
3-
import { StyleValue, useAttrs, watchEffect } from "vue";
4-
import { normalizeStyle, stringifyStyle } from "@vue/shared";
2+
import { Editor } from '@tiptap/core';
3+
import { StyleValue, useAttrs, watchEffect } from 'vue';
4+
import { normalizeStyle, stringifyStyle } from '@vue/shared';
55
66
const props = defineProps<{
77
editor: Editor,
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { Footnote, Footnotes as BaseFootnotes, FootnoteReference } from "tiptap-footnotes";
2+
import { Plugin, PluginKey } from "@tiptap/pm/state";
3+
import { Decoration, DecorationSet } from "@tiptap/pm/view";
4+
import { Extension, mergeAttributes, NodePos } from "@tiptap/core";
5+
6+
export const Footnotes = Extension.create({
7+
name: 'footnotesExtension',
8+
addExtensions() {
9+
return [
10+
BaseFootnotes,
11+
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 p-1 -m-1 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+
},
53+
addCommands() {
54+
return {
55+
focusFootnote: (id: string) => ({ editor, chain }) => {
56+
const matchedFootnote = editor.$node("footnote", {
57+
"data-id": id,
58+
});
59+
60+
if (matchedFootnote) {
61+
// sets the text selection to the end of the footnote definition and scroll to it.
62+
chain()
63+
.focus()
64+
.setTextSelection(
65+
matchedFootnote.from + matchedFootnote.content.size
66+
)
67+
.run();
68+
69+
matchedFootnote.element.scrollIntoView({ block: 'nearest' });
70+
return true;
71+
}
72+
return false;
73+
},
74+
};
75+
},
76+
}),
77+
FootnoteReference
78+
.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+
},
108+
addProseMirrorPlugins() {
109+
const editor = this.editor;
110+
return [
111+
new Plugin({
112+
key: new PluginKey("customFootnoteRefClick"),
113+
114+
props: {
115+
handleDOMEvents: {
116+
click(view, event) {
117+
if((event.target as HTMLElement)?.closest('.footnote-ref')) {
118+
event.preventDefault();
119+
}
120+
}
121+
},
122+
handleClickOn(view, pos, node, nodePos, event) {
123+
if(node.type.name === 'footnoteReference') {
124+
const id = node.attrs["data-id"];
125+
setTimeout(() => editor.commands.focusFootnote(id));
126+
return true;
127+
}
128+
},
129+
},
130+
}),
131+
...this.parent(),
132+
]
133+
},
134+
}),
135+
];
136+
}
137+
})
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+
}

0 commit comments

Comments
 (0)