@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.
- package/bin/commands/import-site.js +2 -10
- package/bin/commands/migrate-text-attachments.js +1 -3
- package/bin/commands/site/export.js +1 -3
- package/bin/commands/telegram/register-webhooks.js +9 -3
- package/bin/lib/site-snapshot.js +1 -5
- package/dist/{app-D-zAhayc.js → app-2i7-WQHg.js} +426 -450
- package/dist/app-DZDlmh7C.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/{client-S1cdvulk.js → client-BvID5ucz.js} +1 -1
- package/dist/client/_assets/client-Cevfpm8H.css +2 -0
- package/dist/client/_assets/{client-auth-YW92ZH7u.js → client-auth-v3yvhbgZ.js} +596 -590
- package/dist/{export-Ckwh4BiE.js → export-DWn149b7.js} +24 -19
- package/dist/{github-sync-BMmK3JZ0.js → github-sync-C0zyF6XS.js} +2 -2
- package/dist/{github-sync-Dl9wQMar.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/__tests__/export-service.test.ts +1 -2
- package/src/__tests__/helpers/app.ts +3 -0
- package/src/__tests__/helpers/export-fixtures.ts +0 -1
- package/src/__tests__/import-site-command.test.ts +1 -2
- package/src/client/__tests__/site-header-nav.test.ts +36 -2
- package/src/client/components/__tests__/compose-title-from-heading.test.ts +57 -0
- 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/__tests__/jant-compose-editor.test.ts +94 -2
- package/src/client/components/__tests__/jant-nav-manager.test.ts +107 -22
- package/src/client/components/__tests__/jant-settings-general.test.ts +1 -0
- package/src/client/components/compose-title-from-heading.ts +66 -0
- package/src/client/components/compose-types.ts +8 -0
- package/src/client/components/inline-image-issues.ts +17 -0
- package/src/client/components/jant-command-palette.ts +100 -70
- package/src/client/components/jant-compose-dialog.ts +3 -1
- package/src/client/components/jant-compose-editor.ts +78 -1
- package/src/client/components/jant-compose-fullscreen.ts +2 -0
- package/src/client/components/jant-nav-manager.ts +245 -155
- package/src/client/components/nav-manager-types.ts +0 -10
- package/src/client/site-header-nav.d.ts +1 -0
- package/src/client/site-header-nav.js +105 -47
- package/src/client/tiptap/__tests__/block-insertion.test.ts +35 -0
- package/src/client/tiptap/__tests__/footnotes.test.ts +284 -0
- package/src/client/tiptap/__tests__/image-input-rules.test.ts +117 -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/create-editor.ts +7 -0
- package/src/client/tiptap/extensions.ts +7 -2
- package/src/client/tiptap/footnotes.ts +156 -0
- package/src/client/tiptap/image-input-rules.ts +48 -0
- package/src/client/tiptap/image-node.ts +189 -7
- package/src/client/tiptap/markdown-clipboard.ts +7 -2
- package/src/client-auth.ts +0 -1
- package/src/i18n/locales/public/en.po +20 -4
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +20 -4
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +20 -4
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +23 -20
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +23 -20
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +23 -20
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +4 -0
- package/src/lib/__tests__/navigation.test.ts +4 -37
- package/src/lib/__tests__/resolve-config.test.ts +6 -1
- package/src/lib/__tests__/view.test.ts +6 -30
- package/src/lib/github-sync-site-config.ts +0 -2
- package/src/lib/hugo-markdown.ts +1 -1
- package/src/lib/markdown-manager.ts +14 -5
- package/src/lib/navigation.ts +5 -22
- package/src/lib/resolve-config.ts +9 -2
- package/src/lib/site-header-fragment.tsx +37 -0
- package/src/lib/view.ts +2 -7
- package/src/node/__tests__/cli-site-snapshot.test.ts +45 -45
- package/src/routes/api/__tests__/nav-items.test.ts +26 -0
- package/src/routes/api/export.ts +0 -2
- package/src/routes/api/internal/__tests__/uploads.test.ts +9 -13
- package/src/routes/api/internal/sites.ts +5 -5
- package/src/routes/api/nav-items.ts +25 -4
- package/src/routes/dash/settings.tsx +8 -2
- package/src/routes/feed/__tests__/feed.test.ts +42 -0
- package/src/routes/feed/__tests__/sitemap.test.ts +2 -13
- package/src/routes/feed/feed.ts +1 -0
- package/src/routes/feed/sitemap.ts +2 -6
- package/src/routes/pages/archive.tsx +1 -0
- package/src/routes/pages/collection.tsx +1 -0
- package/src/routes/pages/featured.tsx +0 -4
- package/src/routes/pages/home.tsx +4 -51
- package/src/routes/pages/latest.tsx +2 -51
- package/src/services/__tests__/github-app-installations.test.ts +10 -10
- package/src/services/__tests__/navigation.test.ts +41 -0
- package/src/services/__tests__/post-timeline.test.ts +56 -0
- package/src/services/__tests__/post.test.ts +33 -0
- package/src/services/__tests__/settings.test.ts +5 -20
- package/src/services/export-theme/assets/client-site.js +10 -10
- package/src/services/export-theme/layouts/_default/rss.xml +5 -15
- package/src/services/export-theme/layouts/index.html +26 -39
- package/src/services/export.ts +5 -10
- package/src/services/github-app-installations.ts +1 -3
- package/src/services/navigation.ts +44 -13
- package/src/services/post.ts +80 -57
- package/src/services/settings.ts +0 -10
- package/src/services/site-admin.ts +0 -2
- package/src/services/upload-session.ts +4 -18
- package/src/styles/tokens.css +2 -2
- package/src/styles/ui.css +316 -34
- package/src/types/bindings.ts +0 -1
- package/src/types/config.ts +3 -7
- package/src/ui/compose/ComposeDialog.tsx +28 -0
- package/src/ui/dash/appearance/NavigationContent.tsx +1 -1
- package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
- package/src/ui/dash/settings/SettingsRootContent.tsx +3 -3
- 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 +266 -227
- package/src/ui/layouts/__tests__/SiteLayout.test.tsx +48 -0
- package/dist/app-B5P1b4TF.js +0 -6
- package/dist/client/_assets/client-BUxR-E8O.css +0 -2
- package/src/client/nav-manager-bridge.ts +0 -54
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* Manages the "More" dropdown popover.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
const moreControllers = new WeakMap();
|
|
9
|
+
const drawerControllers = new WeakMap();
|
|
10
|
+
|
|
8
11
|
function initMoreDropdown(root) {
|
|
9
12
|
const trigger = root.querySelector(".site-header-more-btn");
|
|
10
13
|
const popover = root.querySelector(".site-header-more-popover");
|
|
@@ -12,6 +15,9 @@ function initMoreDropdown(root) {
|
|
|
12
15
|
if (!trigger || !popover) return;
|
|
13
16
|
if (trigger.dataset.moreInitialized === "true") return;
|
|
14
17
|
trigger.dataset.moreInitialized = "true";
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
const signal = controller.signal;
|
|
20
|
+
moreControllers.set(trigger, controller);
|
|
15
21
|
|
|
16
22
|
function open() {
|
|
17
23
|
popover.setAttribute("aria-hidden", "false");
|
|
@@ -29,30 +35,49 @@ function initMoreDropdown(root) {
|
|
|
29
35
|
if (focusTrigger) trigger.focus();
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
trigger.addEventListener(
|
|
33
|
-
|
|
34
|
-
e
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
38
|
+
trigger.addEventListener(
|
|
39
|
+
"click",
|
|
40
|
+
(e) => {
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
e.stopPropagation();
|
|
43
|
+
if (trigger.getAttribute("aria-expanded") === "true") {
|
|
44
|
+
close();
|
|
45
|
+
} else {
|
|
46
|
+
open();
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
{ signal },
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
document.addEventListener(
|
|
53
|
+
"click",
|
|
54
|
+
(e) => {
|
|
55
|
+
if (!(e.target instanceof Node)) return;
|
|
56
|
+
if (!trigger.parentElement?.contains(e.target)) close();
|
|
57
|
+
},
|
|
58
|
+
{ signal },
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
document.addEventListener(
|
|
62
|
+
"keydown",
|
|
63
|
+
(e) => {
|
|
64
|
+
if (
|
|
65
|
+
e.key === "Escape" &&
|
|
66
|
+
popover.getAttribute("aria-hidden") === "false"
|
|
67
|
+
) {
|
|
68
|
+
close(true);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
{ signal },
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
document.addEventListener(
|
|
75
|
+
"basecoat:popover",
|
|
76
|
+
(e) => {
|
|
77
|
+
if (e.detail?.source !== trigger.parentElement) close();
|
|
78
|
+
},
|
|
79
|
+
{ signal },
|
|
80
|
+
);
|
|
56
81
|
}
|
|
57
82
|
|
|
58
83
|
const FRESH_VISIT_KEY = "jant:nav-fresh-visits";
|
|
@@ -111,6 +136,9 @@ export function initSiteHeaderNav(root = document) {
|
|
|
111
136
|
if (!hamburger || !drawer || !backdrop) return;
|
|
112
137
|
if (hamburger.dataset.drawerInitialized === "true") return;
|
|
113
138
|
hamburger.dataset.drawerInitialized = "true";
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
const signal = controller.signal;
|
|
141
|
+
drawerControllers.set(hamburger, controller);
|
|
114
142
|
|
|
115
143
|
function open() {
|
|
116
144
|
drawer.setAttribute("aria-hidden", "false");
|
|
@@ -138,35 +166,65 @@ export function initSiteHeaderNav(root = document) {
|
|
|
138
166
|
drawer.setAttribute("inert", "");
|
|
139
167
|
}
|
|
140
168
|
},
|
|
141
|
-
{ once: true },
|
|
169
|
+
{ once: true, signal },
|
|
142
170
|
);
|
|
143
171
|
|
|
144
172
|
if (returnFocus) hamburger.focus();
|
|
145
173
|
}
|
|
146
174
|
|
|
147
|
-
hamburger.addEventListener(
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
175
|
+
hamburger.addEventListener(
|
|
176
|
+
"click",
|
|
177
|
+
() => {
|
|
178
|
+
if (hamburger.getAttribute("aria-expanded") === "true") {
|
|
179
|
+
close();
|
|
180
|
+
} else {
|
|
181
|
+
open();
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
{ signal },
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
closeBtn?.addEventListener("click", () => close(), { signal });
|
|
188
|
+
backdrop.addEventListener("click", () => close(), { signal });
|
|
189
|
+
drawer.addEventListener(
|
|
190
|
+
"click",
|
|
191
|
+
(event) => {
|
|
192
|
+
if (!(event.target instanceof Element)) return;
|
|
193
|
+
if (event.target.closest("a[href]")) {
|
|
194
|
+
close(false);
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
{ signal },
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
drawer.addEventListener(
|
|
201
|
+
"keydown",
|
|
202
|
+
(event) => {
|
|
203
|
+
if (event.key === "Escape") {
|
|
204
|
+
event.preventDefault();
|
|
205
|
+
close();
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
{ signal },
|
|
209
|
+
);
|
|
210
|
+
}
|
|
163
211
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
212
|
+
export function destroySiteHeaderNav(root = document) {
|
|
213
|
+
for (const trigger of root.querySelectorAll(".site-header-more-btn")) {
|
|
214
|
+
const controller = moreControllers.get(trigger);
|
|
215
|
+
controller?.abort();
|
|
216
|
+
moreControllers.delete(trigger);
|
|
217
|
+
delete trigger.dataset.moreInitialized;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
for (const hamburger of root.querySelectorAll(".site-header-hamburger")) {
|
|
221
|
+
const controller = drawerControllers.get(hamburger);
|
|
222
|
+
controller?.abort();
|
|
223
|
+
drawerControllers.delete(hamburger);
|
|
224
|
+
delete hamburger.dataset.drawerInitialized;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
document.documentElement.classList.remove("drawer-open");
|
|
170
228
|
}
|
|
171
229
|
|
|
172
230
|
initSiteHeaderNav();
|
|
@@ -62,4 +62,39 @@ describe("Block insertion flow", () => {
|
|
|
62
62
|
expect(editor.state.selection.$from.parent.type.name).toBe("paragraph");
|
|
63
63
|
expect(editor.state.selection.$from.parentOffset).toBe(0);
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
it("renders a removable placeholder when an image fails to load", () => {
|
|
67
|
+
const editor = createEditor();
|
|
68
|
+
|
|
69
|
+
editor.commands.focus("start");
|
|
70
|
+
expect(editor.commands.setImage({ src: "/uploads/missing.webp" })).toBe(
|
|
71
|
+
true,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const figure = document.querySelector<HTMLElement>(".tiptap-image-figure");
|
|
75
|
+
const img = figure?.querySelector<HTMLImageElement>("img");
|
|
76
|
+
if (!figure || !img) {
|
|
77
|
+
throw new Error("expected image node view");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
img.dispatchEvent(new Event("error"));
|
|
81
|
+
|
|
82
|
+
expect(figure.dataset.loadState).toBe("missing");
|
|
83
|
+
expect(figure.textContent).toContain("Image unavailable");
|
|
84
|
+
|
|
85
|
+
const deleteButton = figure.querySelector<HTMLButtonElement>(
|
|
86
|
+
".tiptap-image-missing-delete",
|
|
87
|
+
);
|
|
88
|
+
if (!deleteButton) {
|
|
89
|
+
throw new Error("expected broken image delete button");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
deleteButton.dispatchEvent(
|
|
93
|
+
new MouseEvent("mousedown", { bubbles: true, cancelable: true }),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(
|
|
97
|
+
editor.getJSON().content?.some((node) => node.type === "image"),
|
|
98
|
+
).toBe(false);
|
|
99
|
+
});
|
|
65
100
|
});
|
|
@@ -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",
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { Editor } from "@tiptap/core";
|
|
5
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
6
|
+
import { ImageInputRules } from "../image-input-rules.js";
|
|
7
|
+
import { ImageNode } from "../image-node.js";
|
|
8
|
+
import { LinkInputRules } from "../link-input-rules.js";
|
|
9
|
+
|
|
10
|
+
const editors: Editor[] = [];
|
|
11
|
+
|
|
12
|
+
function createEditor(): Editor {
|
|
13
|
+
const element = document.createElement("div");
|
|
14
|
+
document.body.appendChild(element);
|
|
15
|
+
|
|
16
|
+
const editor = new Editor({
|
|
17
|
+
element,
|
|
18
|
+
extensions: [
|
|
19
|
+
StarterKit.configure({
|
|
20
|
+
heading: { levels: [1, 2, 3] },
|
|
21
|
+
link: { openOnClick: false, autolink: false },
|
|
22
|
+
}),
|
|
23
|
+
LinkInputRules,
|
|
24
|
+
ImageNode,
|
|
25
|
+
ImageInputRules,
|
|
26
|
+
],
|
|
27
|
+
content: "<p></p>",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
editors.push(editor);
|
|
31
|
+
return editor;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function type(editor: Editor, text: string): void {
|
|
35
|
+
const view = editor.view;
|
|
36
|
+
for (const ch of text) {
|
|
37
|
+
const { from, to } = view.state.selection;
|
|
38
|
+
const handled = view.someProp("handleTextInput", (handler) =>
|
|
39
|
+
handler(view, from, to, ch, () => view.state.tr.insertText(ch, from, to)),
|
|
40
|
+
);
|
|
41
|
+
if (!handled) view.dispatch(view.state.tr.insertText(ch, from, to));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
while (editors.length > 0) {
|
|
47
|
+
editors.pop()?.destroy();
|
|
48
|
+
}
|
|
49
|
+
document.body.innerHTML = "";
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("ImageInputRules", () => {
|
|
53
|
+
it("converts typed empty-alt Markdown image syntax into an image node", () => {
|
|
54
|
+
const editor = createEditor();
|
|
55
|
+
|
|
56
|
+
type(editor, "");
|
|
57
|
+
|
|
58
|
+
expect(editor.getJSON().content).toEqual([
|
|
59
|
+
{
|
|
60
|
+
type: "image",
|
|
61
|
+
attrs: {
|
|
62
|
+
src: "https://example.com/image.png",
|
|
63
|
+
alt: "",
|
|
64
|
+
title: "",
|
|
65
|
+
caption: "",
|
|
66
|
+
href: "",
|
|
67
|
+
layout: "regular",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{ type: "paragraph" },
|
|
71
|
+
]);
|
|
72
|
+
expect(editor.state.selection.$from.parent.type.name).toBe("paragraph");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("preserves alt text and optional quoted title", () => {
|
|
76
|
+
const editor = createEditor();
|
|
77
|
+
|
|
78
|
+
type(editor, '');
|
|
79
|
+
|
|
80
|
+
expect(editor.getJSON().content?.[0]).toMatchObject({
|
|
81
|
+
type: "image",
|
|
82
|
+
attrs: {
|
|
83
|
+
src: "https://example.com/image.png",
|
|
84
|
+
alt: "A test image",
|
|
85
|
+
title: "Title",
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("runs before Markdown link rules", () => {
|
|
91
|
+
const editor = createEditor();
|
|
92
|
+
|
|
93
|
+
type(editor, "");
|
|
94
|
+
|
|
95
|
+
expect(editor.getJSON().content?.[0]).toMatchObject({
|
|
96
|
+
type: "image",
|
|
97
|
+
attrs: {
|
|
98
|
+
src: "https://example.com/image.png",
|
|
99
|
+
alt: "Alt",
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
expect(editor.state.doc.textContent).toBe("");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("leaves unsafe image URLs as typed text", () => {
|
|
106
|
+
const editor = createEditor();
|
|
107
|
+
|
|
108
|
+
type(editor, "");
|
|
109
|
+
|
|
110
|
+
expect(editor.getJSON().content).toEqual([
|
|
111
|
+
{
|
|
112
|
+
type: "paragraph",
|
|
113
|
+
content: [{ type: "text", text: "" }],
|
|
114
|
+
},
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
});
|