@jant/core 0.6.13 → 0.6.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{app-HnzmsaqU.js → app-2i7-WQHg.js} +26 -22
- package/dist/app-DZDlmh7C.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/{client-3_OaxhTs.js → client-BvID5ucz.js} +1 -1
- package/dist/client/_assets/client-Cevfpm8H.css +2 -0
- package/dist/client/_assets/{client-auth-TsYq90xm.js → client-auth-v3yvhbgZ.js} +175 -172
- package/dist/{export-Dfbq9aot.js → export-DWn149b7.js} +14 -6
- package/dist/{github-sync-BGTLx8Mf.js → github-sync-C0zyF6XS.js} +2 -2
- package/dist/{github-sync--PSoE-Ne.js → github-sync-Ckk0sJxK.js} +1 -1
- package/dist/index.js +3 -3
- package/dist/node.js +4 -4
- package/package.json +1 -1
- package/src/client/components/__tests__/jant-command-palette.test.ts +137 -10
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +3 -0
- package/src/client/components/jant-command-palette.ts +100 -70
- package/src/client/components/jant-compose-dialog.ts +1 -1
- package/src/client/tiptap/__tests__/footnotes.test.ts +284 -0
- package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +148 -1
- package/src/client/tiptap/bubble-menu.ts +3 -3
- package/src/client/tiptap/footnotes.ts +156 -0
- package/src/client/tiptap/markdown-clipboard.ts +7 -2
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +4 -0
- package/src/lib/markdown-manager.ts +14 -5
- package/src/styles/ui.css +116 -28
- package/src/ui/feed/__tests__/timeline-cards.test.ts +8 -3
- package/src/ui/layouts/BaseLayout.tsx +1 -1
- package/src/ui/layouts/SiteLayout.tsx +19 -13
- package/src/ui/layouts/__tests__/SiteLayout.test.tsx +48 -0
- package/dist/app-CWJFPjuR.js +0 -6
- package/dist/client/_assets/client-DCrcHVJl.css +0 -2
|
@@ -132,6 +132,89 @@ function pressDelete(editor: Editor): boolean {
|
|
|
132
132
|
return pressKey(editor, "Delete");
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
function findFootnoteReferencePos(editor: Editor, label: string): number {
|
|
136
|
+
let referencePos: number | null = null;
|
|
137
|
+
|
|
138
|
+
editor.state.doc.descendants((node, pos) => {
|
|
139
|
+
if (
|
|
140
|
+
referencePos === null &&
|
|
141
|
+
node.type.name === "footnoteReference" &&
|
|
142
|
+
node.attrs.label === label
|
|
143
|
+
) {
|
|
144
|
+
referencePos = pos;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return true;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (referencePos === null) {
|
|
151
|
+
throw new Error(`expected footnote reference ${label}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return referencePos;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function clickFootnoteReference(editor: Editor, label: string): boolean {
|
|
158
|
+
const pos = findFootnoteReferencePos(editor, label);
|
|
159
|
+
const node = editor.state.doc.nodeAt(pos);
|
|
160
|
+
if (!node) {
|
|
161
|
+
throw new Error(`expected footnote reference node ${label}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const event = new window.MouseEvent("click", {
|
|
165
|
+
bubbles: true,
|
|
166
|
+
button: 0,
|
|
167
|
+
});
|
|
168
|
+
let handled = false;
|
|
169
|
+
|
|
170
|
+
editor.view.someProp("handleClickOn", (handleClickOn) => {
|
|
171
|
+
if (handleClickOn(editor.view, pos, node, pos, event, true)) {
|
|
172
|
+
handled = true;
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return false;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return handled;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function clickBeforeFootnoteDefinition(editor: Editor, label: string): boolean {
|
|
183
|
+
let definitionPos: number | null = null;
|
|
184
|
+
|
|
185
|
+
editor.state.doc.forEach((node, offset) => {
|
|
186
|
+
if (
|
|
187
|
+
definitionPos === null &&
|
|
188
|
+
node.type.name === "footnoteDefinition" &&
|
|
189
|
+
node.attrs.label === label
|
|
190
|
+
) {
|
|
191
|
+
definitionPos = offset;
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (definitionPos === null) {
|
|
196
|
+
throw new Error(`expected footnote definition ${label}`);
|
|
197
|
+
}
|
|
198
|
+
const targetPos = definitionPos;
|
|
199
|
+
|
|
200
|
+
const event = new window.MouseEvent("click", {
|
|
201
|
+
bubbles: true,
|
|
202
|
+
button: 0,
|
|
203
|
+
});
|
|
204
|
+
let handled = false;
|
|
205
|
+
|
|
206
|
+
editor.view.someProp("handleClick", (handleClick) => {
|
|
207
|
+
if (handleClick(editor.view, targetPos, event)) {
|
|
208
|
+
handled = true;
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return false;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
return handled;
|
|
216
|
+
}
|
|
217
|
+
|
|
135
218
|
afterEach(() => {
|
|
136
219
|
while (editors.length > 0) {
|
|
137
220
|
editors.pop()?.destroy();
|
|
@@ -140,6 +223,108 @@ afterEach(() => {
|
|
|
140
223
|
});
|
|
141
224
|
|
|
142
225
|
describe("Footnotes editor extension", () => {
|
|
226
|
+
it("adds an empty definition when initial content has only a reference", async () => {
|
|
227
|
+
const editor = createEditor({
|
|
228
|
+
type: "doc",
|
|
229
|
+
content: [
|
|
230
|
+
{
|
|
231
|
+
type: "paragraph",
|
|
232
|
+
content: [
|
|
233
|
+
{ type: "text", text: "Body" },
|
|
234
|
+
{ type: "footnoteReference", attrs: { label: "1" } },
|
|
235
|
+
],
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
241
|
+
|
|
242
|
+
expect(normalizeFootnoteArtifacts(editor.getJSON()).content?.[1]).toEqual({
|
|
243
|
+
type: "footnoteDefinition",
|
|
244
|
+
attrs: { label: "1" },
|
|
245
|
+
content: [{ type: "paragraph" }],
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("adds empty definitions for missing references after a document change", () => {
|
|
250
|
+
const editor = createEditor();
|
|
251
|
+
|
|
252
|
+
editor.commands.setContent({
|
|
253
|
+
type: "doc",
|
|
254
|
+
content: [
|
|
255
|
+
{
|
|
256
|
+
type: "paragraph",
|
|
257
|
+
content: [
|
|
258
|
+
{ type: "text", text: "Body" },
|
|
259
|
+
{ type: "footnoteReference", attrs: { label: "Note" } },
|
|
260
|
+
{ type: "text", text: " and again" },
|
|
261
|
+
{ type: "footnoteReference", attrs: { label: "note" } },
|
|
262
|
+
],
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
type: "blockquote",
|
|
266
|
+
content: [
|
|
267
|
+
{
|
|
268
|
+
type: "paragraph",
|
|
269
|
+
content: [
|
|
270
|
+
{ type: "text", text: "Nested" },
|
|
271
|
+
{ type: "footnoteReference", attrs: { label: "2" } },
|
|
272
|
+
],
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(
|
|
280
|
+
normalizeFootnoteArtifacts(editor.getJSON()).content?.slice(-2),
|
|
281
|
+
).toEqual([
|
|
282
|
+
{
|
|
283
|
+
type: "footnoteDefinition",
|
|
284
|
+
attrs: { label: "Note" },
|
|
285
|
+
content: [{ type: "paragraph" }],
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
type: "footnoteDefinition",
|
|
289
|
+
attrs: { label: "2" },
|
|
290
|
+
content: [{ type: "paragraph" }],
|
|
291
|
+
},
|
|
292
|
+
]);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("does not duplicate an existing case-insensitive definition", () => {
|
|
296
|
+
const editor = createEditor();
|
|
297
|
+
|
|
298
|
+
editor.commands.setContent({
|
|
299
|
+
type: "doc",
|
|
300
|
+
content: [
|
|
301
|
+
{
|
|
302
|
+
type: "paragraph",
|
|
303
|
+
content: [
|
|
304
|
+
{ type: "text", text: "Body" },
|
|
305
|
+
{ type: "footnoteReference", attrs: { label: "Note" } },
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
type: "footnoteDefinition",
|
|
310
|
+
attrs: { label: "note" },
|
|
311
|
+
content: [
|
|
312
|
+
{
|
|
313
|
+
type: "paragraph",
|
|
314
|
+
content: [{ type: "text", text: "Existing note" }],
|
|
315
|
+
},
|
|
316
|
+
],
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
expect(
|
|
322
|
+
editor
|
|
323
|
+
.getJSON()
|
|
324
|
+
.content?.filter((node) => node.type === "footnoteDefinition"),
|
|
325
|
+
).toHaveLength(1);
|
|
326
|
+
});
|
|
327
|
+
|
|
143
328
|
it("insertFootnote inserts a reference, appends a definition, and moves selection into the definition", () => {
|
|
144
329
|
const editor = createEditor({
|
|
145
330
|
type: "doc",
|
|
@@ -178,6 +363,105 @@ describe("Footnotes editor extension", () => {
|
|
|
178
363
|
).toBe("footnoteDefinition");
|
|
179
364
|
});
|
|
180
365
|
|
|
366
|
+
it("clicking an orphan reference creates and focuses its definition", () => {
|
|
367
|
+
const editor = createEditor({
|
|
368
|
+
type: "doc",
|
|
369
|
+
content: [
|
|
370
|
+
{
|
|
371
|
+
type: "paragraph",
|
|
372
|
+
content: [
|
|
373
|
+
{ type: "text", text: "Body" },
|
|
374
|
+
{ type: "footnoteReference", attrs: { label: "1" } },
|
|
375
|
+
],
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
});
|
|
379
|
+
expect(clickFootnoteReference(editor, "1")).toBe(true);
|
|
380
|
+
|
|
381
|
+
expect(normalizeFootnoteArtifacts(editor.getJSON()).content?.[1]).toEqual({
|
|
382
|
+
type: "footnoteDefinition",
|
|
383
|
+
attrs: { label: "1" },
|
|
384
|
+
content: [{ type: "paragraph" }],
|
|
385
|
+
});
|
|
386
|
+
expect(editor.state.selection.$from.parent.type.name).toBe("paragraph");
|
|
387
|
+
expect(
|
|
388
|
+
editor.state.selection.$from.node(editor.state.selection.$from.depth - 1)
|
|
389
|
+
.type.name,
|
|
390
|
+
).toBe("footnoteDefinition");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("pressing Enter on a selected reference focuses its definition", () => {
|
|
394
|
+
const editor = createEditor({
|
|
395
|
+
type: "doc",
|
|
396
|
+
content: [
|
|
397
|
+
{
|
|
398
|
+
type: "paragraph",
|
|
399
|
+
content: [
|
|
400
|
+
{ type: "text", text: "Body" },
|
|
401
|
+
{ type: "footnoteReference", attrs: { label: "1" } },
|
|
402
|
+
],
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
type: "footnoteDefinition",
|
|
406
|
+
attrs: { label: "1" },
|
|
407
|
+
content: [
|
|
408
|
+
{
|
|
409
|
+
type: "paragraph",
|
|
410
|
+
content: [{ type: "text", text: "Existing note" }],
|
|
411
|
+
},
|
|
412
|
+
],
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
});
|
|
416
|
+
editor.commands.setNodeSelection(findFootnoteReferencePos(editor, "1"));
|
|
417
|
+
|
|
418
|
+
expect(pressEnter(editor)).toBe(true);
|
|
419
|
+
expect(
|
|
420
|
+
editor.state.selection.$from.node(editor.state.selection.$from.depth - 1)
|
|
421
|
+
.type.name,
|
|
422
|
+
).toBe("footnoteDefinition");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("clicking between adjacent definitions focuses the next definition", () => {
|
|
426
|
+
const editor = createEditor({
|
|
427
|
+
type: "doc",
|
|
428
|
+
content: [
|
|
429
|
+
{
|
|
430
|
+
type: "paragraph",
|
|
431
|
+
content: [{ type: "text", text: "Body" }],
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
type: "footnoteDefinition",
|
|
435
|
+
attrs: { label: "1" },
|
|
436
|
+
content: [
|
|
437
|
+
{
|
|
438
|
+
type: "paragraph",
|
|
439
|
+
content: [{ type: "text", text: "First note" }],
|
|
440
|
+
},
|
|
441
|
+
],
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
type: "footnoteDefinition",
|
|
445
|
+
attrs: { label: "2" },
|
|
446
|
+
content: [
|
|
447
|
+
{
|
|
448
|
+
type: "paragraph",
|
|
449
|
+
content: [{ type: "text", text: "Second note" }],
|
|
450
|
+
},
|
|
451
|
+
],
|
|
452
|
+
},
|
|
453
|
+
],
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
expect(clickBeforeFootnoteDefinition(editor, "2")).toBe(true);
|
|
457
|
+
|
|
458
|
+
const definitionNode = editor.state.selection.$from.node(
|
|
459
|
+
editor.state.selection.$from.depth - 1,
|
|
460
|
+
);
|
|
461
|
+
expect(definitionNode.type.name).toBe("footnoteDefinition");
|
|
462
|
+
expect(definitionNode.attrs.label).toBe("2");
|
|
463
|
+
});
|
|
464
|
+
|
|
181
465
|
it("assigns the next numeric label when a footnote already exists", () => {
|
|
182
466
|
const editor = createEditor({
|
|
183
467
|
type: "doc",
|
|
@@ -1,6 +1,33 @@
|
|
|
1
|
-
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import type { Editor } from "@tiptap/core";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { createTiptapEditor } from "../create-editor.js";
|
|
2
6
|
import { isCodeEditorHtml } from "../markdown-clipboard.js";
|
|
3
7
|
|
|
8
|
+
const editors: Editor[] = [];
|
|
9
|
+
|
|
10
|
+
function dispatchMarkdownPaste(editor: Editor, text: string, html = "") {
|
|
11
|
+
const event = new Event("paste", {
|
|
12
|
+
bubbles: true,
|
|
13
|
+
cancelable: true,
|
|
14
|
+
}) as Event & { clipboardData: unknown };
|
|
15
|
+
event.clipboardData = {
|
|
16
|
+
getData: (type: string) =>
|
|
17
|
+
type === "text/plain" ? text : type === "text/html" ? html : "",
|
|
18
|
+
files: [],
|
|
19
|
+
items: [],
|
|
20
|
+
types: html ? ["text/html", "text/plain"] : ["text/plain"],
|
|
21
|
+
};
|
|
22
|
+
editor.commands.focus();
|
|
23
|
+
editor.view.dom.dispatchEvent(event);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
while (editors.length > 0) editors.pop()?.destroy();
|
|
28
|
+
document.body.innerHTML = "";
|
|
29
|
+
});
|
|
30
|
+
|
|
4
31
|
describe("isCodeEditorHtml", () => {
|
|
5
32
|
it("detects VS Code HTML via data-vscode attribute", () => {
|
|
6
33
|
const html = `<meta charset='utf-8'><div style="color: #d4d4d4;background-color: #1e1e1e;" data-vscode-theme-name="Default Dark+"><div style="line-height:18px"><span style="color: #569cd6;">const</span> x = 1;</div></div>`;
|
|
@@ -46,3 +73,123 @@ describe("isCodeEditorHtml", () => {
|
|
|
46
73
|
expect(isCodeEditorHtml(html)).toBe(false);
|
|
47
74
|
});
|
|
48
75
|
});
|
|
76
|
+
|
|
77
|
+
describe("MarkdownClipboard", () => {
|
|
78
|
+
it.each([
|
|
79
|
+
["plain text", ""],
|
|
80
|
+
[
|
|
81
|
+
"code editor text",
|
|
82
|
+
'<div data-vscode-theme-name="Default Dark+">Markdown</div>',
|
|
83
|
+
],
|
|
84
|
+
])("pastes a footnote from %s without adding an empty line", (_, html) => {
|
|
85
|
+
const element = document.createElement("div");
|
|
86
|
+
document.body.appendChild(element);
|
|
87
|
+
const editor = createTiptapEditor({ element });
|
|
88
|
+
editors.push(editor);
|
|
89
|
+
|
|
90
|
+
dispatchMarkdownPaste(
|
|
91
|
+
editor,
|
|
92
|
+
"Body with a footnote.[^1]\n\n[^1]: Footnote body",
|
|
93
|
+
html,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(editor.getJSON()).toEqual({
|
|
97
|
+
type: "doc",
|
|
98
|
+
content: [
|
|
99
|
+
{
|
|
100
|
+
type: "paragraph",
|
|
101
|
+
content: [
|
|
102
|
+
{ type: "text", text: "Body with a footnote." },
|
|
103
|
+
{ type: "footnoteReference", attrs: { label: "1" } },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
type: "footnoteDefinition",
|
|
108
|
+
attrs: { label: "1" },
|
|
109
|
+
content: [
|
|
110
|
+
{
|
|
111
|
+
type: "paragraph",
|
|
112
|
+
content: [{ type: "text", text: "Footnote body" }],
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it.each([
|
|
121
|
+
["plain text", ""],
|
|
122
|
+
[
|
|
123
|
+
"code editor text",
|
|
124
|
+
'<div data-vscode-theme-name="Default Dark+">Markdown</div>',
|
|
125
|
+
],
|
|
126
|
+
])("adds an empty definition for an orphan footnote from %s", (_, html) => {
|
|
127
|
+
const element = document.createElement("div");
|
|
128
|
+
document.body.appendChild(element);
|
|
129
|
+
const editor = createTiptapEditor({ element });
|
|
130
|
+
editors.push(editor);
|
|
131
|
+
|
|
132
|
+
dispatchMarkdownPaste(editor, "Body with a footnote.[^1]", html);
|
|
133
|
+
|
|
134
|
+
expect(editor.getJSON()).toEqual({
|
|
135
|
+
type: "doc",
|
|
136
|
+
content: [
|
|
137
|
+
{
|
|
138
|
+
type: "paragraph",
|
|
139
|
+
content: [
|
|
140
|
+
{ type: "text", text: "Body with a footnote." },
|
|
141
|
+
{ type: "footnoteReference", attrs: { label: "1" } },
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
type: "footnoteDefinition",
|
|
146
|
+
attrs: { label: "1" },
|
|
147
|
+
content: [{ type: "paragraph" }],
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("keeps the formatting toolbar hidden for a selected footnote node", () => {
|
|
154
|
+
const element = document.createElement("div");
|
|
155
|
+
document.body.appendChild(element);
|
|
156
|
+
const editor = createTiptapEditor({
|
|
157
|
+
element,
|
|
158
|
+
content: {
|
|
159
|
+
type: "doc",
|
|
160
|
+
content: [
|
|
161
|
+
{
|
|
162
|
+
type: "paragraph",
|
|
163
|
+
content: [
|
|
164
|
+
{ type: "text", text: "Body" },
|
|
165
|
+
{ type: "footnoteReference", attrs: { label: "1" } },
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
type: "footnoteDefinition",
|
|
170
|
+
attrs: { label: "1" },
|
|
171
|
+
content: [{ type: "paragraph" }],
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
editors.push(editor);
|
|
177
|
+
|
|
178
|
+
let referencePos: number | null = null;
|
|
179
|
+
editor.state.doc.descendants((node, pos) => {
|
|
180
|
+
if (node.type.name === "footnoteReference") {
|
|
181
|
+
referencePos = pos;
|
|
182
|
+
}
|
|
183
|
+
return true;
|
|
184
|
+
});
|
|
185
|
+
if (referencePos === null) {
|
|
186
|
+
throw new Error("expected footnote reference");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
editor.commands.setNodeSelection(referencePos);
|
|
190
|
+
|
|
191
|
+
expect(
|
|
192
|
+
document.querySelector<HTMLElement>(".tiptap-bubble-menu")?.style.display,
|
|
193
|
+
).toBe("none");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { Extension, type Editor } from "@tiptap/core";
|
|
10
|
-
import { Plugin, PluginKey, Selection } from "@tiptap/pm/state";
|
|
10
|
+
import { Plugin, PluginKey, Selection, TextSelection } from "@tiptap/pm/state";
|
|
11
11
|
import type { EditorView } from "@tiptap/pm/view";
|
|
12
12
|
import { isLinkToolbarInputActive } from "./link-toolbar.js";
|
|
13
13
|
import type { FormattingToolbarMode } from "./toolbar-mode.js";
|
|
@@ -280,9 +280,9 @@ export const BubbleMenu = Extension.create({
|
|
|
280
280
|
function shouldShow(view: EditorView): boolean {
|
|
281
281
|
const { state } = view;
|
|
282
282
|
const { selection } = state;
|
|
283
|
-
const { empty } = selection;
|
|
284
283
|
// Only show for non-empty text selections (not node selections)
|
|
285
|
-
if (
|
|
284
|
+
if (!(selection instanceof TextSelection) || selection.empty)
|
|
285
|
+
return false;
|
|
286
286
|
if (!selection.$from.parent.isTextblock) return false;
|
|
287
287
|
// Hide when link input popup is open
|
|
288
288
|
if (isLinkToolbarInputActive()) return false;
|
|
@@ -2,6 +2,7 @@ import { Extension, InputRule } from "@tiptap/core";
|
|
|
2
2
|
import { Fragment, type Node as ProseMirrorNode } from "@tiptap/pm/model";
|
|
3
3
|
import {
|
|
4
4
|
NodeSelection,
|
|
5
|
+
Plugin,
|
|
5
6
|
TextSelection,
|
|
6
7
|
type EditorState,
|
|
7
8
|
type Transaction,
|
|
@@ -195,6 +196,85 @@ function ensureFootnoteDefinition(
|
|
|
195
196
|
return insertPos;
|
|
196
197
|
}
|
|
197
198
|
|
|
199
|
+
function collectMissingFootnoteDefinitionLabels(
|
|
200
|
+
doc: ProseMirrorNode,
|
|
201
|
+
): string[] {
|
|
202
|
+
const definedLabels = new Set<string>();
|
|
203
|
+
const queuedLabels = new Set<string>();
|
|
204
|
+
const missingLabels: string[] = [];
|
|
205
|
+
|
|
206
|
+
doc.forEach((node) => {
|
|
207
|
+
if (node.type.name !== "footnoteDefinition") {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const label = normalizeFootnoteLabel(node.attrs.label);
|
|
212
|
+
if (label) {
|
|
213
|
+
definedLabels.add(getFootnoteLabelKey(label));
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
doc.descendants((node) => {
|
|
218
|
+
if (node.type.name !== "footnoteReference") {
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const label = normalizeFootnoteLabel(node.attrs.label);
|
|
223
|
+
const labelKey = getFootnoteLabelKey(label);
|
|
224
|
+
if (label && !definedLabels.has(labelKey) && !queuedLabels.has(labelKey)) {
|
|
225
|
+
queuedLabels.add(labelKey);
|
|
226
|
+
missingLabels.push(label);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return true;
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return missingLabels;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function buildEnsureFootnoteDefinitionsTransaction(
|
|
236
|
+
state: EditorState,
|
|
237
|
+
): Transaction | null {
|
|
238
|
+
const missingLabels = collectMissingFootnoteDefinitionLabels(state.doc);
|
|
239
|
+
if (missingLabels.length === 0) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const tr = state.tr;
|
|
244
|
+
for (const label of missingLabels) {
|
|
245
|
+
ensureFootnoteDefinition(state, tr, label);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return tr.docChanged ? tr : null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function getSelectedFootnoteReferenceLabel(state: EditorState): string | null {
|
|
252
|
+
const { selection } = state;
|
|
253
|
+
if (
|
|
254
|
+
!(selection instanceof NodeSelection) ||
|
|
255
|
+
selection.node.type.name !== "footnoteReference"
|
|
256
|
+
) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return normalizeFootnoteLabel(selection.node.attrs.label) || null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function buildNavigateToFootnoteDefinitionTransaction(
|
|
264
|
+
state: EditorState,
|
|
265
|
+
label: string,
|
|
266
|
+
): Transaction | null {
|
|
267
|
+
const tr = state.tr;
|
|
268
|
+
const definitionPos = ensureFootnoteDefinition(state, tr, label);
|
|
269
|
+
if (definitionPos === null) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
setSelectionInsideInsertedFootnote(tr, definitionPos);
|
|
274
|
+
tr.scrollIntoView();
|
|
275
|
+
return tr;
|
|
276
|
+
}
|
|
277
|
+
|
|
198
278
|
function ensureNonEmptyDoc(state: EditorState, tr: Transaction) {
|
|
199
279
|
if (tr.doc.childCount > 0) {
|
|
200
280
|
return;
|
|
@@ -603,6 +683,66 @@ declare module "@tiptap/core" {
|
|
|
603
683
|
export const Footnotes = Extension.create({
|
|
604
684
|
name: "footnotes",
|
|
605
685
|
|
|
686
|
+
onCreate() {
|
|
687
|
+
const tr = buildEnsureFootnoteDefinitionsTransaction(this.editor.state);
|
|
688
|
+
if (tr) {
|
|
689
|
+
this.editor.view.dispatch(tr);
|
|
690
|
+
}
|
|
691
|
+
},
|
|
692
|
+
|
|
693
|
+
addProseMirrorPlugins() {
|
|
694
|
+
return [
|
|
695
|
+
new Plugin({
|
|
696
|
+
appendTransaction: (transactions, _oldState, newState) => {
|
|
697
|
+
if (!transactions.some((transaction) => transaction.docChanged)) {
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return buildEnsureFootnoteDefinitionsTransaction(newState);
|
|
702
|
+
},
|
|
703
|
+
props: {
|
|
704
|
+
handleClickOn: (view, _pos, node, _nodePos, _event, direct) => {
|
|
705
|
+
if (!direct || node.type.name !== "footnoteReference") {
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const label = normalizeFootnoteLabel(node.attrs.label);
|
|
710
|
+
if (!label) {
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const tr = buildNavigateToFootnoteDefinitionTransaction(
|
|
715
|
+
view.state,
|
|
716
|
+
label,
|
|
717
|
+
);
|
|
718
|
+
if (!tr) {
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
view.dispatch(tr);
|
|
723
|
+
view.focus();
|
|
724
|
+
return true;
|
|
725
|
+
},
|
|
726
|
+
handleClick: (view, pos) => {
|
|
727
|
+
const $pos = view.state.doc.resolve(pos);
|
|
728
|
+
if (
|
|
729
|
+
$pos.nodeBefore?.type.name !== "footnoteDefinition" ||
|
|
730
|
+
$pos.nodeAfter?.type.name !== "footnoteDefinition"
|
|
731
|
+
) {
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const tr = view.state.tr;
|
|
736
|
+
setSelectionInsideInsertedFootnote(tr, pos);
|
|
737
|
+
view.dispatch(tr.scrollIntoView());
|
|
738
|
+
view.focus();
|
|
739
|
+
return true;
|
|
740
|
+
},
|
|
741
|
+
},
|
|
742
|
+
}),
|
|
743
|
+
];
|
|
744
|
+
},
|
|
745
|
+
|
|
606
746
|
addCommands() {
|
|
607
747
|
return {
|
|
608
748
|
insertFootnote:
|
|
@@ -649,6 +789,22 @@ export const Footnotes = Extension.create({
|
|
|
649
789
|
addKeyboardShortcuts() {
|
|
650
790
|
return {
|
|
651
791
|
Enter: ({ editor }) => {
|
|
792
|
+
const selectedReferenceLabel = getSelectedFootnoteReferenceLabel(
|
|
793
|
+
editor.state,
|
|
794
|
+
);
|
|
795
|
+
if (selectedReferenceLabel) {
|
|
796
|
+
const navigateTr = buildNavigateToFootnoteDefinitionTransaction(
|
|
797
|
+
editor.state,
|
|
798
|
+
selectedReferenceLabel,
|
|
799
|
+
);
|
|
800
|
+
if (!navigateTr) {
|
|
801
|
+
return false;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
editor.view.dispatch(navigateTr);
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
807
|
+
|
|
652
808
|
const tr = activateTrailingFootnoteAtParagraphEnd(editor.state);
|
|
653
809
|
if (!tr) {
|
|
654
810
|
return false;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createNodeFromContent, Extension } from "@tiptap/core";
|
|
2
2
|
import { Fragment, Slice } from "@tiptap/pm/model";
|
|
3
3
|
import { Plugin } from "@tiptap/pm/state";
|
|
4
|
+
import { normalizeMarkdownDocument } from "../../lib/markdown-manager.js";
|
|
4
5
|
|
|
5
6
|
function toFragment(
|
|
6
7
|
content: ReturnType<typeof createNodeFromContent>,
|
|
@@ -62,7 +63,9 @@ export const MarkdownClipboard = Extension.create({
|
|
|
62
63
|
event.clipboardData?.getData("text/plain")?.trim() ?? "";
|
|
63
64
|
if (!text || !this.editor.markdown) return false;
|
|
64
65
|
|
|
65
|
-
const parsed =
|
|
66
|
+
const parsed = normalizeMarkdownDocument(
|
|
67
|
+
this.editor.markdown.parse(text),
|
|
68
|
+
);
|
|
66
69
|
if (parsed.type !== "doc" || !parsed.content) return false;
|
|
67
70
|
|
|
68
71
|
const content = createNodeFromContent(parsed, view.state.schema, {
|
|
@@ -82,7 +85,9 @@ export const MarkdownClipboard = Extension.create({
|
|
|
82
85
|
return Slice.empty;
|
|
83
86
|
}
|
|
84
87
|
|
|
85
|
-
const parsed =
|
|
88
|
+
const parsed = normalizeMarkdownDocument(
|
|
89
|
+
this.editor.markdown.parse(text),
|
|
90
|
+
);
|
|
86
91
|
if (parsed.type !== "doc" || !parsed.content) {
|
|
87
92
|
return Slice.empty;
|
|
88
93
|
}
|
|
@@ -119,6 +119,10 @@ describe("markdownToTiptapJson", () => {
|
|
|
119
119
|
it("converts footnote definitions into dedicated block nodes", () => {
|
|
120
120
|
const doc = parse("Body[^1]\n\n[^1]: Footnote body");
|
|
121
121
|
|
|
122
|
+
expect(doc.content[0].content).toEqual([
|
|
123
|
+
{ type: "text", text: "Body" },
|
|
124
|
+
{ type: "footnoteReference", attrs: { label: "1" } },
|
|
125
|
+
]);
|
|
122
126
|
expect(doc.content[1].type).toBe("footnoteDefinition");
|
|
123
127
|
expect(doc.content[1].attrs.label).toBe("1");
|
|
124
128
|
expect(doc.content[1].content[0].type).toBe("paragraph");
|