@jant/core 0.6.12 → 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.
Files changed (120) hide show
  1. package/bin/commands/import-site.js +2 -10
  2. package/bin/commands/migrate-text-attachments.js +1 -3
  3. package/bin/commands/site/export.js +1 -3
  4. package/bin/commands/telegram/register-webhooks.js +9 -3
  5. package/bin/lib/site-snapshot.js +1 -5
  6. package/dist/{app-D-zAhayc.js → app-2i7-WQHg.js} +426 -450
  7. package/dist/app-DZDlmh7C.js +6 -0
  8. package/dist/client/.vite/manifest.json +3 -3
  9. package/dist/client/_assets/{client-S1cdvulk.js → client-BvID5ucz.js} +1 -1
  10. package/dist/client/_assets/client-Cevfpm8H.css +2 -0
  11. package/dist/client/_assets/{client-auth-YW92ZH7u.js → client-auth-v3yvhbgZ.js} +596 -590
  12. package/dist/{export-Ckwh4BiE.js → export-DWn149b7.js} +24 -19
  13. package/dist/{github-sync-BMmK3JZ0.js → github-sync-C0zyF6XS.js} +2 -2
  14. package/dist/{github-sync-Dl9wQMar.js → github-sync-Ckk0sJxK.js} +1 -1
  15. package/dist/index.js +3 -3
  16. package/dist/node.js +4 -4
  17. package/package.json +1 -1
  18. package/src/__tests__/export-service.test.ts +1 -2
  19. package/src/__tests__/helpers/app.ts +3 -0
  20. package/src/__tests__/helpers/export-fixtures.ts +0 -1
  21. package/src/__tests__/import-site-command.test.ts +1 -2
  22. package/src/client/__tests__/site-header-nav.test.ts +36 -2
  23. package/src/client/components/__tests__/compose-title-from-heading.test.ts +57 -0
  24. package/src/client/components/__tests__/jant-command-palette.test.ts +137 -10
  25. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3 -0
  26. package/src/client/components/__tests__/jant-compose-editor.test.ts +94 -2
  27. package/src/client/components/__tests__/jant-nav-manager.test.ts +107 -22
  28. package/src/client/components/__tests__/jant-settings-general.test.ts +1 -0
  29. package/src/client/components/compose-title-from-heading.ts +66 -0
  30. package/src/client/components/compose-types.ts +8 -0
  31. package/src/client/components/inline-image-issues.ts +17 -0
  32. package/src/client/components/jant-command-palette.ts +100 -70
  33. package/src/client/components/jant-compose-dialog.ts +3 -1
  34. package/src/client/components/jant-compose-editor.ts +78 -1
  35. package/src/client/components/jant-compose-fullscreen.ts +2 -0
  36. package/src/client/components/jant-nav-manager.ts +245 -155
  37. package/src/client/components/nav-manager-types.ts +0 -10
  38. package/src/client/site-header-nav.d.ts +1 -0
  39. package/src/client/site-header-nav.js +105 -47
  40. package/src/client/tiptap/__tests__/block-insertion.test.ts +35 -0
  41. package/src/client/tiptap/__tests__/footnotes.test.ts +284 -0
  42. package/src/client/tiptap/__tests__/image-input-rules.test.ts +117 -0
  43. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +148 -1
  44. package/src/client/tiptap/bubble-menu.ts +3 -3
  45. package/src/client/tiptap/create-editor.ts +7 -0
  46. package/src/client/tiptap/extensions.ts +7 -2
  47. package/src/client/tiptap/footnotes.ts +156 -0
  48. package/src/client/tiptap/image-input-rules.ts +48 -0
  49. package/src/client/tiptap/image-node.ts +189 -7
  50. package/src/client/tiptap/markdown-clipboard.ts +7 -2
  51. package/src/client-auth.ts +0 -1
  52. package/src/i18n/locales/public/en.po +20 -4
  53. package/src/i18n/locales/public/en.ts +1 -1
  54. package/src/i18n/locales/public/zh-Hans.po +20 -4
  55. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  56. package/src/i18n/locales/public/zh-Hant.po +20 -4
  57. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  58. package/src/i18n/locales/settings/en.po +23 -20
  59. package/src/i18n/locales/settings/en.ts +1 -1
  60. package/src/i18n/locales/settings/zh-Hans.po +23 -20
  61. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  62. package/src/i18n/locales/settings/zh-Hant.po +23 -20
  63. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  64. package/src/lib/__tests__/markdown-to-tiptap.test.ts +4 -0
  65. package/src/lib/__tests__/navigation.test.ts +4 -37
  66. package/src/lib/__tests__/resolve-config.test.ts +6 -1
  67. package/src/lib/__tests__/view.test.ts +6 -30
  68. package/src/lib/github-sync-site-config.ts +0 -2
  69. package/src/lib/hugo-markdown.ts +1 -1
  70. package/src/lib/markdown-manager.ts +14 -5
  71. package/src/lib/navigation.ts +5 -22
  72. package/src/lib/resolve-config.ts +9 -2
  73. package/src/lib/site-header-fragment.tsx +37 -0
  74. package/src/lib/view.ts +2 -7
  75. package/src/node/__tests__/cli-site-snapshot.test.ts +45 -45
  76. package/src/routes/api/__tests__/nav-items.test.ts +26 -0
  77. package/src/routes/api/export.ts +0 -2
  78. package/src/routes/api/internal/__tests__/uploads.test.ts +9 -13
  79. package/src/routes/api/internal/sites.ts +5 -5
  80. package/src/routes/api/nav-items.ts +25 -4
  81. package/src/routes/dash/settings.tsx +8 -2
  82. package/src/routes/feed/__tests__/feed.test.ts +42 -0
  83. package/src/routes/feed/__tests__/sitemap.test.ts +2 -13
  84. package/src/routes/feed/feed.ts +1 -0
  85. package/src/routes/feed/sitemap.ts +2 -6
  86. package/src/routes/pages/archive.tsx +1 -0
  87. package/src/routes/pages/collection.tsx +1 -0
  88. package/src/routes/pages/featured.tsx +0 -4
  89. package/src/routes/pages/home.tsx +4 -51
  90. package/src/routes/pages/latest.tsx +2 -51
  91. package/src/services/__tests__/github-app-installations.test.ts +10 -10
  92. package/src/services/__tests__/navigation.test.ts +41 -0
  93. package/src/services/__tests__/post-timeline.test.ts +56 -0
  94. package/src/services/__tests__/post.test.ts +33 -0
  95. package/src/services/__tests__/settings.test.ts +5 -20
  96. package/src/services/export-theme/assets/client-site.js +10 -10
  97. package/src/services/export-theme/layouts/_default/rss.xml +5 -15
  98. package/src/services/export-theme/layouts/index.html +26 -39
  99. package/src/services/export.ts +5 -10
  100. package/src/services/github-app-installations.ts +1 -3
  101. package/src/services/navigation.ts +44 -13
  102. package/src/services/post.ts +80 -57
  103. package/src/services/settings.ts +0 -10
  104. package/src/services/site-admin.ts +0 -2
  105. package/src/services/upload-session.ts +4 -18
  106. package/src/styles/tokens.css +2 -2
  107. package/src/styles/ui.css +316 -34
  108. package/src/types/bindings.ts +0 -1
  109. package/src/types/config.ts +3 -7
  110. package/src/ui/compose/ComposeDialog.tsx +28 -0
  111. package/src/ui/dash/appearance/NavigationContent.tsx +1 -1
  112. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  113. package/src/ui/dash/settings/SettingsRootContent.tsx +3 -3
  114. package/src/ui/feed/__tests__/timeline-cards.test.ts +8 -3
  115. package/src/ui/layouts/BaseLayout.tsx +1 -1
  116. package/src/ui/layouts/SiteLayout.tsx +266 -227
  117. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +48 -0
  118. package/dist/app-B5P1b4TF.js +0 -6
  119. package/dist/client/_assets/client-BUxR-E8O.css +0 -2
  120. package/src/client/nav-manager-bridge.ts +0 -54
@@ -1,6 +1,33 @@
1
- import { describe, expect, it } from "vitest";
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 (empty) return false;
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;
@@ -12,6 +12,7 @@ import {
12
12
  import type { FormattingToolbarMode } from "./toolbar-mode.js";
13
13
  import type { PasteMediaOptions } from "./paste-media.js";
14
14
  import type { RehostImagesOptions } from "./rehost-images.js";
15
+ import type { ImageNodeLabels } from "./image-node.js";
15
16
  import { normalizeFootnoteArtifacts } from "../../lib/footnotes.js";
16
17
  import { tiptapJsonToMarkdown } from "../../lib/tiptap-to-markdown.js";
17
18
  import { parseMarkdownDocument } from "../../lib/markdown-manager.js";
@@ -22,10 +23,12 @@ export interface CreateEditorOptions {
22
23
  content?: JSONContent | null;
23
24
  onUpdate?: (json: JSONContent) => void;
24
25
  onFocus?: () => void;
26
+ onBlur?: () => void;
25
27
  onSelectionUpdate?: (selection: { from: number; to: number }) => void;
26
28
  toolbarMode?: FormattingToolbarMode;
27
29
  pasteMedia?: PasteMediaOptions;
28
30
  rehostImages?: RehostImagesOptions;
31
+ imageNodeLabels?: Partial<ImageNodeLabels>;
29
32
  }
30
33
 
31
34
  /**
@@ -42,6 +45,7 @@ export function createTiptapEditor(options: CreateEditorOptions): Editor {
42
45
  toolbarMode: options.toolbarMode,
43
46
  pasteMedia: options.pasteMedia,
44
47
  rehostImages: options.rehostImages,
48
+ imageNodeLabels: options.imageNodeLabels,
45
49
  }),
46
50
  content: options.content ?? undefined,
47
51
  editorProps: {
@@ -54,6 +58,9 @@ export function createTiptapEditor(options: CreateEditorOptions): Editor {
54
58
  onFocus: () => {
55
59
  options.onFocus?.();
56
60
  },
61
+ onBlur: () => {
62
+ options.onBlur?.();
63
+ },
57
64
  onSelectionUpdate: ({ editor }) => {
58
65
  options.onSelectionUpdate?.({
59
66
  from: editor.state.selection.from,
@@ -19,7 +19,7 @@ import { WrappingInputRules } from "./wrapping-input-rules.js";
19
19
  import { InsertParagraphAround } from "./insert-paragraph-around.js";
20
20
  import { Footnotes } from "./footnotes.js";
21
21
  import type { FormattingToolbarMode } from "./toolbar-mode.js";
22
- import { ImageNode } from "./image-node.js";
22
+ import { ImageNode, type ImageNodeLabels } from "./image-node.js";
23
23
  import { MoreBreak } from "./more-break.js";
24
24
  import { EmbedNode } from "./embed-node.js";
25
25
  import { HtmlBlockNode } from "./html-block-node.js";
@@ -27,6 +27,7 @@ import { EmbedPaste } from "./embed-paste.js";
27
27
  import { RehostImages } from "./rehost-images.js";
28
28
  import type { RehostImagesOptions } from "./rehost-images.js";
29
29
  import { MarkdownClipboard } from "./markdown-clipboard.js";
30
+ import { ImageInputRules } from "./image-input-rules.js";
30
31
  import {
31
32
  MARKDOWN_MARKED_OPTIONS,
32
33
  createMarkdownContentExtensions,
@@ -37,6 +38,7 @@ export interface EditorExtensionOptions {
37
38
  toolbarMode?: FormattingToolbarMode;
38
39
  pasteMedia?: PasteMediaOptions;
39
40
  rehostImages?: RehostImagesOptions;
41
+ imageNodeLabels?: Partial<ImageNodeLabels>;
40
42
  }
41
43
 
42
44
  /**
@@ -97,7 +99,9 @@ export function createEditorExtensions(
97
99
  ): Extensions {
98
100
  return [
99
101
  ...createMarkdownContentExtensions({
100
- imageExtension: ImageNode,
102
+ imageExtension: ImageNode.configure({
103
+ labels: options.imageNodeLabels,
104
+ }),
101
105
  moreBreakExtension: MoreBreak,
102
106
  embedExtension: EmbedNode,
103
107
  htmlBlockExtension: HtmlBlockNode,
@@ -110,6 +114,7 @@ export function createEditorExtensions(
110
114
  placeholder: options.placeholder ?? "Write something…",
111
115
  }),
112
116
  Footnotes,
117
+ ImageInputRules,
113
118
  LinkInputRules,
114
119
  WrappingInputRules,
115
120
  MarkdownClipboard,
@@ -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;
@@ -0,0 +1,48 @@
1
+ import { Extension, InputRule } from "@tiptap/core";
2
+ import { sanitizeUrl } from "../../lib/url.js";
3
+ import { moveSelectionAfterBlockInsertion } from "./block-insertion.js";
4
+
5
+ // Keep the URL group aligned with link-input-rules: single-character
6
+ // alternatives avoid catastrophic backtracking on failed Markdown matches.
7
+ const MARKDOWN_IMAGE_INPUT_REGEX =
8
+ /!\[([^\]\n]*)\]\(((?:[^()\s"]|\([^()\s"]*\))+)(?:\s*"([^"\n]*)")?\)$/;
9
+
10
+ function sanitizeImageSrc(src: string): string {
11
+ const sanitized = sanitizeUrl(src.trim());
12
+ if (!sanitized || /^mailto:/i.test(sanitized)) return "";
13
+ return sanitized;
14
+ }
15
+
16
+ export const ImageInputRules = Extension.create({
17
+ name: "imageInputRules",
18
+
19
+ // Runs before LinkInputRules so `![alt](url)` is handled as an image rather
20
+ // than the inner `[alt](url)` being converted to a link.
21
+ priority: 1000,
22
+
23
+ addInputRules() {
24
+ const imageType = this.editor.schema.nodes.image;
25
+ if (!imageType) return [];
26
+
27
+ return [
28
+ new InputRule({
29
+ find: MARKDOWN_IMAGE_INPUT_REGEX,
30
+ handler: ({ state, range, match }) => {
31
+ const src = sanitizeImageSrc(match[2] ?? "");
32
+ if (!src) return null;
33
+
34
+ state.tr.replaceRangeWith(
35
+ range.from,
36
+ range.to,
37
+ imageType.create({
38
+ src,
39
+ alt: match[1] ?? "",
40
+ title: match[3] ?? "",
41
+ }),
42
+ );
43
+ moveSelectionAfterBlockInsertion(state.tr, state.schema);
44
+ },
45
+ }),
46
+ ];
47
+ },
48
+ });