@jant/core 0.6.11 → 0.6.13
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 +47 -30
- 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-CWJFPjuR.js +6 -0
- package/dist/{app-CpmficmQ.js → app-HnzmsaqU.js} +721 -473
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/{client-Dd9U383b.js → client-3_OaxhTs.js} +1 -1
- package/dist/client/_assets/client-DCrcHVJl.css +2 -0
- package/dist/client/_assets/{client-auth-DkpSdIDz.js → client-auth-TsYq90xm.js} +625 -561
- package/dist/{export-Ba7NJImL.js → export-Dfbq9aot.js} +12 -15
- package/dist/{github-sync-Cb4_6_i7.js → github-sync--PSoE-Ne.js} +1 -1
- package/dist/{github-sync-BD4w2m8-.js → github-sync-BGTLx8Mf.js} +2 -2
- 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 +76 -2
- package/src/__tests__/vite-dev-plugins.test.ts +34 -0
- package/src/client/__tests__/compose-launch.test.ts +50 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +74 -18
- 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 +63 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +33 -0
- package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +20 -1
- package/src/client/components/__tests__/jant-compose-editor.test.ts +94 -2
- package/src/client/components/__tests__/jant-nav-manager.test.ts +181 -20
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +6 -0
- package/src/client/components/__tests__/jant-settings-general.test.ts +72 -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 +67 -2
- package/src/client/components/jant-compose-dialog.ts +10 -2
- 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 +382 -152
- package/src/client/components/jant-settings-general.ts +56 -0
- package/src/client/components/nav-manager-types.ts +14 -10
- package/src/client/components/settings-types.ts +30 -0
- package/src/client/compose-launch.ts +30 -0
- package/src/client/compose-shortcuts.ts +46 -3
- 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__/image-input-rules.test.ts +117 -0
- package/src/client/tiptap/__tests__/rehost-images.test.ts +66 -13
- package/src/client/tiptap/create-editor.ts +7 -0
- package/src/client/tiptap/extensions.ts +7 -2
- package/src/client/tiptap/image-input-rules.ts +48 -0
- package/src/client/tiptap/image-node.ts +189 -7
- package/src/client/tiptap/rehost-images.ts +5 -1
- package/src/client-auth.ts +0 -1
- package/src/i18n/__tests__/fallback.test.ts +24 -0
- 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 +22 -6
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +22 -6
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +77 -20
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +85 -28
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +79 -22
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/feed.test.ts +96 -10
- 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/feed.ts +63 -3
- package/src/lib/github-sync-site-config.ts +0 -2
- package/src/lib/hugo-markdown.ts +1 -1
- package/src/lib/navigation.ts +5 -22
- package/src/lib/post-display.ts +14 -8
- 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/preset.css +60 -18
- 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 +48 -3
- 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/__tests__/draft-about-edit.test.ts +65 -0
- 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/routes/pages/page.tsx +44 -12
- package/src/services/__tests__/about-page.test.ts +116 -0
- package/src/services/__tests__/github-app-installations.test.ts +10 -10
- package/src/services/__tests__/navigation.test.ts +231 -1
- package/src/services/__tests__/post-timeline.test.ts +175 -2
- package/src/services/__tests__/post.test.ts +33 -0
- package/src/services/__tests__/settings.test.ts +5 -20
- package/src/services/about-page.ts +143 -0
- package/src/services/export-theme/assets/client-site.js +10 -10
- package/src/services/export-theme/layouts/_default/rss.xml +7 -16
- package/src/services/export-theme/layouts/index.html +26 -39
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +25 -1
- package/src/services/export-theme/styles/main.css +46 -14
- package/src/services/export.ts +5 -10
- package/src/services/github-app-installations.ts +1 -3
- package/src/services/index.ts +27 -17
- package/src/services/navigation.ts +186 -1
- package/src/services/post.ts +103 -63
- 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/components.css +20 -0
- package/src/styles/tokens.css +28 -10
- package/src/styles/ui.css +349 -13
- package/src/types/bindings.ts +0 -1
- package/src/types/config.ts +3 -7
- package/src/types/views.ts +13 -0
- package/src/ui/compose/ComposeDialog.tsx +28 -0
- package/src/ui/dash/appearance/NavigationContent.tsx +58 -1
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +19 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
- package/src/ui/dash/settings/GeneralContent.tsx +44 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +3 -3
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +8 -0
- package/src/ui/feed/__tests__/timeline-cards.test.ts +162 -1
- package/src/ui/layouts/SiteLayout.tsx +249 -216
- package/dist/app-DqKkZenB.js +0 -6
- package/dist/client/_assets/client-BhHHVvSY.css +0 -2
- package/src/client/nav-manager-bridge.ts +0 -54
|
@@ -52,6 +52,22 @@ function track(src: string): string {
|
|
|
52
52
|
|
|
53
53
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
54
54
|
|
|
55
|
+
function dispatchPaste(editor: Editor, html: string, text = "") {
|
|
56
|
+
const event = new Event("paste", {
|
|
57
|
+
bubbles: true,
|
|
58
|
+
cancelable: true,
|
|
59
|
+
}) as Event & { clipboardData: unknown };
|
|
60
|
+
event.clipboardData = {
|
|
61
|
+
getData: (type: string) =>
|
|
62
|
+
type === "text/html" ? html : type === "text/plain" ? text : "",
|
|
63
|
+
files: [],
|
|
64
|
+
items: [],
|
|
65
|
+
types: ["text/html", "text/plain"],
|
|
66
|
+
};
|
|
67
|
+
editor.commands.focus();
|
|
68
|
+
editor.view.dom.dispatchEvent(event);
|
|
69
|
+
}
|
|
70
|
+
|
|
55
71
|
afterEach(() => {
|
|
56
72
|
while (editors.length > 0) editors.pop()?.destroy();
|
|
57
73
|
for (const src of usedSrcs) clearRehostInFlight(src);
|
|
@@ -62,12 +78,12 @@ afterEach(() => {
|
|
|
62
78
|
});
|
|
63
79
|
|
|
64
80
|
describe("RehostImages extension", () => {
|
|
65
|
-
it("fires rehost once for a remote image", async () => {
|
|
81
|
+
it("fires rehost once for a pasted remote image", async () => {
|
|
66
82
|
const rehost = vi.fn();
|
|
67
83
|
const editor = createEditor(rehost);
|
|
68
84
|
const src = track("https://ext.example/a-remote.png");
|
|
69
85
|
|
|
70
|
-
editor
|
|
86
|
+
dispatchPaste(editor, `<p><img src="${src}"></p>`);
|
|
71
87
|
await flush();
|
|
72
88
|
|
|
73
89
|
expect(rehost).toHaveBeenCalledTimes(1);
|
|
@@ -79,7 +95,7 @@ describe("RehostImages extension", () => {
|
|
|
79
95
|
const editor = createEditor(rehost);
|
|
80
96
|
const src = track("data:image/png;base64,AAAAb");
|
|
81
97
|
|
|
82
|
-
editor
|
|
98
|
+
dispatchPaste(editor, `<p><img src="${src}"></p>`);
|
|
83
99
|
await flush();
|
|
84
100
|
|
|
85
101
|
expect(rehost).toHaveBeenCalledWith(src);
|
|
@@ -89,13 +105,15 @@ describe("RehostImages extension", () => {
|
|
|
89
105
|
const rehost = vi.fn();
|
|
90
106
|
document.documentElement.dataset.mediaBase = "https://cdn.mysite.test";
|
|
91
107
|
const editor = createEditor(rehost);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
108
|
+
const sameOrigin = track(`${window.location.origin}/m/x.png`);
|
|
109
|
+
const relative = track("/relative/y.png");
|
|
110
|
+
const blob = track("blob:abc-123");
|
|
111
|
+
const mediaBase = track("https://cdn.mysite.test/z.png");
|
|
112
|
+
|
|
113
|
+
dispatchPaste(
|
|
114
|
+
editor,
|
|
115
|
+
`<p><img src="${sameOrigin}"><img src="${relative}"><img src="${blob}"><img src="${mediaBase}"></p>`,
|
|
116
|
+
);
|
|
99
117
|
await flush();
|
|
100
118
|
|
|
101
119
|
expect(rehost).not.toHaveBeenCalled();
|
|
@@ -106,8 +124,43 @@ describe("RehostImages extension", () => {
|
|
|
106
124
|
const editor = createEditor(rehost);
|
|
107
125
|
const src = track("https://ext.example/dupe.png");
|
|
108
126
|
|
|
109
|
-
editor
|
|
110
|
-
|
|
127
|
+
dispatchPaste(editor, `<p><img src="${src}"><img src="${src}"></p>`);
|
|
128
|
+
await flush();
|
|
129
|
+
|
|
130
|
+
expect(rehost).toHaveBeenCalledTimes(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("does not rehost remote images loaded through setContent", async () => {
|
|
134
|
+
const rehost = vi.fn();
|
|
135
|
+
const editor = createEditor(rehost);
|
|
136
|
+
const src = track("https://ext.example/saved-external.png");
|
|
137
|
+
|
|
138
|
+
editor.commands.setContent({
|
|
139
|
+
type: "doc",
|
|
140
|
+
content: [
|
|
141
|
+
{ type: "paragraph" },
|
|
142
|
+
{
|
|
143
|
+
type: "image",
|
|
144
|
+
attrs: { src },
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
});
|
|
148
|
+
await flush();
|
|
149
|
+
|
|
150
|
+
expect(rehost).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("does not retry a failed paste rehost on later edits", async () => {
|
|
154
|
+
const rehost = vi.fn();
|
|
155
|
+
const editor = createEditor(rehost);
|
|
156
|
+
const src = track("https://ext.example/blocked.png");
|
|
157
|
+
|
|
158
|
+
dispatchPaste(editor, `<p><img src="${src}"></p>`);
|
|
159
|
+
await flush();
|
|
160
|
+
expect(rehost).toHaveBeenCalledTimes(1);
|
|
161
|
+
|
|
162
|
+
clearRehostInFlight(src);
|
|
163
|
+
editor.commands.insertContent("later edit");
|
|
111
164
|
await flush();
|
|
112
165
|
|
|
113
166
|
expect(rehost).toHaveBeenCalledTimes(1);
|
|
@@ -118,7 +171,7 @@ describe("RehostImages extension", () => {
|
|
|
118
171
|
const editor = createEditor(rehost);
|
|
119
172
|
const src = track("https://ext.example/swap.png");
|
|
120
173
|
|
|
121
|
-
editor
|
|
174
|
+
dispatchPaste(editor, `<p><img src="${src}"></p>`);
|
|
122
175
|
await flush();
|
|
123
176
|
expect(rehost).toHaveBeenCalledTimes(1);
|
|
124
177
|
|
|
@@ -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,
|
|
@@ -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 `` 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
|
+
});
|
|
@@ -20,6 +20,22 @@ import type { EditorView } from "@tiptap/pm/view";
|
|
|
20
20
|
import { uploadWithMetadata } from "../upload-with-metadata.js";
|
|
21
21
|
import { renderMarkdownImage } from "../../lib/rich-image.js";
|
|
22
22
|
|
|
23
|
+
export const INLINE_IMAGE_LOAD_STATE_EVENT = "jant:inline-image-load-state";
|
|
24
|
+
|
|
25
|
+
export interface ImageNodeLabels {
|
|
26
|
+
unavailable: string;
|
|
27
|
+
delete: string;
|
|
28
|
+
replace: string;
|
|
29
|
+
open: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_IMAGE_NODE_LABELS: ImageNodeLabels = {
|
|
33
|
+
unavailable: "Image unavailable",
|
|
34
|
+
delete: "Delete image",
|
|
35
|
+
replace: "Replace image",
|
|
36
|
+
open: "Open image URL",
|
|
37
|
+
};
|
|
38
|
+
|
|
23
39
|
declare module "@tiptap/core" {
|
|
24
40
|
interface Commands<ReturnType> {
|
|
25
41
|
image: {
|
|
@@ -40,6 +56,8 @@ declare module "@tiptap/core" {
|
|
|
40
56
|
// ---------------------------------------------------------------------------
|
|
41
57
|
|
|
42
58
|
const ICONS = {
|
|
59
|
+
/** Broken image placeholder */
|
|
60
|
+
imageBroken: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2.5"/><circle cx="8.5" cy="9.5" r="1.5"/><path d="m5 17 4.25-4.25 2.5 2.5 1.75-1.75L19 19"/><path d="m4 4 16 16"/></svg>`,
|
|
43
61
|
/** Content-width — centered column */
|
|
44
62
|
regular: `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="3" y="3" width="10" height="10" rx="1.5"/></svg>`,
|
|
45
63
|
/** Wide — max 1200 px breakout */
|
|
@@ -62,6 +80,9 @@ class ImageNodeView {
|
|
|
62
80
|
dom: HTMLElement;
|
|
63
81
|
|
|
64
82
|
private img: HTMLImageElement;
|
|
83
|
+
private missingPanel: HTMLElement;
|
|
84
|
+
private missingSource: HTMLElement;
|
|
85
|
+
private openBtn: HTMLButtonElement;
|
|
65
86
|
private figcaption: HTMLElement;
|
|
66
87
|
private captionInput: HTMLInputElement;
|
|
67
88
|
private altBtn: HTMLButtonElement;
|
|
@@ -73,25 +94,31 @@ class ImageNodeView {
|
|
|
73
94
|
private view: EditorView;
|
|
74
95
|
private getPos: () => number | undefined;
|
|
75
96
|
private editor: Editor;
|
|
97
|
+
private labels: ImageNodeLabels;
|
|
76
98
|
|
|
77
99
|
private editingAlt = false;
|
|
100
|
+
private missing = false;
|
|
101
|
+
private currentSrc = "";
|
|
78
102
|
|
|
79
103
|
constructor(
|
|
80
104
|
node: ProseMirrorNode,
|
|
81
105
|
view: EditorView,
|
|
82
106
|
getPos: () => number | undefined,
|
|
83
107
|
editor: Editor,
|
|
108
|
+
labels: ImageNodeLabels,
|
|
84
109
|
) {
|
|
85
110
|
this.node = node;
|
|
86
111
|
this.view = view;
|
|
87
112
|
this.getPos = getPos;
|
|
88
113
|
this.editor = editor;
|
|
114
|
+
this.labels = labels;
|
|
89
115
|
|
|
90
116
|
// --- Build DOM tree ---
|
|
91
117
|
const figure = document.createElement("figure");
|
|
92
118
|
figure.className = "tiptap-image-figure";
|
|
93
119
|
figure.dataset.selected = "false";
|
|
94
120
|
figure.dataset.layout = String(node.attrs.layout || "regular");
|
|
121
|
+
figure.dataset.loadState = "loading";
|
|
95
122
|
this.dom = figure;
|
|
96
123
|
|
|
97
124
|
// Image container
|
|
@@ -101,13 +128,78 @@ class ImageNodeView {
|
|
|
101
128
|
|
|
102
129
|
// <img>
|
|
103
130
|
const img = document.createElement("img");
|
|
104
|
-
img.src = String(node.attrs.src ?? "");
|
|
105
|
-
img.alt = String(node.attrs.alt ?? "");
|
|
106
|
-
if (node.attrs.title) img.title = String(node.attrs.title);
|
|
107
131
|
img.draggable = false;
|
|
132
|
+
img.addEventListener("load", () => this.setMissing(false));
|
|
133
|
+
img.addEventListener("error", () => this.setMissing(true));
|
|
108
134
|
container.appendChild(img);
|
|
109
135
|
this.img = img;
|
|
110
136
|
|
|
137
|
+
// --- Missing image panel ---
|
|
138
|
+
const missingPanel = document.createElement("div");
|
|
139
|
+
missingPanel.className = "tiptap-image-missing";
|
|
140
|
+
missingPanel.role = "group";
|
|
141
|
+
missingPanel.tabIndex = 0;
|
|
142
|
+
missingPanel.setAttribute("aria-label", labels.unavailable);
|
|
143
|
+
missingPanel.addEventListener("keydown", (e) => {
|
|
144
|
+
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
|
145
|
+
e.preventDefault();
|
|
146
|
+
this.deleteNode();
|
|
147
|
+
});
|
|
148
|
+
container.appendChild(missingPanel);
|
|
149
|
+
this.missingPanel = missingPanel;
|
|
150
|
+
|
|
151
|
+
const missingIcon = document.createElement("span");
|
|
152
|
+
missingIcon.className = "tiptap-image-missing-icon";
|
|
153
|
+
missingIcon.setAttribute("aria-hidden", "true");
|
|
154
|
+
missingIcon.innerHTML = ICONS.imageBroken;
|
|
155
|
+
missingPanel.appendChild(missingIcon);
|
|
156
|
+
|
|
157
|
+
const missingCopy = document.createElement("span");
|
|
158
|
+
missingCopy.className = "tiptap-image-missing-copy";
|
|
159
|
+
missingPanel.appendChild(missingCopy);
|
|
160
|
+
|
|
161
|
+
const missingTitle = document.createElement("strong");
|
|
162
|
+
missingTitle.textContent = labels.unavailable;
|
|
163
|
+
missingCopy.appendChild(missingTitle);
|
|
164
|
+
|
|
165
|
+
const missingSource = document.createElement("span");
|
|
166
|
+
missingSource.className = "tiptap-image-missing-source";
|
|
167
|
+
missingCopy.appendChild(missingSource);
|
|
168
|
+
this.missingSource = missingSource;
|
|
169
|
+
|
|
170
|
+
const missingActions = document.createElement("span");
|
|
171
|
+
missingActions.className = "tiptap-image-missing-actions";
|
|
172
|
+
missingPanel.appendChild(missingActions);
|
|
173
|
+
|
|
174
|
+
const deleteBtn = document.createElement("button");
|
|
175
|
+
deleteBtn.type = "button";
|
|
176
|
+
deleteBtn.className = "tiptap-image-missing-delete";
|
|
177
|
+
deleteBtn.textContent = labels.delete;
|
|
178
|
+
deleteBtn.addEventListener("mousedown", (e) => {
|
|
179
|
+
e.preventDefault();
|
|
180
|
+
this.deleteNode();
|
|
181
|
+
});
|
|
182
|
+
missingActions.appendChild(deleteBtn);
|
|
183
|
+
|
|
184
|
+
const replaceMissingBtn = document.createElement("button");
|
|
185
|
+
replaceMissingBtn.type = "button";
|
|
186
|
+
replaceMissingBtn.textContent = labels.replace;
|
|
187
|
+
replaceMissingBtn.addEventListener("mousedown", (e) => {
|
|
188
|
+
e.preventDefault();
|
|
189
|
+
this.handleReplace();
|
|
190
|
+
});
|
|
191
|
+
missingActions.appendChild(replaceMissingBtn);
|
|
192
|
+
|
|
193
|
+
const openBtn = document.createElement("button");
|
|
194
|
+
openBtn.type = "button";
|
|
195
|
+
openBtn.textContent = labels.open;
|
|
196
|
+
openBtn.addEventListener("mousedown", (e) => {
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
this.handleOpenOriginal();
|
|
199
|
+
});
|
|
200
|
+
missingActions.appendChild(openBtn);
|
|
201
|
+
this.openBtn = openBtn;
|
|
202
|
+
|
|
111
203
|
// --- Toolbar (shown when selected) ---
|
|
112
204
|
const toolbar = document.createElement("div");
|
|
113
205
|
toolbar.className = "tiptap-image-toolbar";
|
|
@@ -206,6 +298,8 @@ class ImageNodeView {
|
|
|
206
298
|
figcaption.textContent = String(node.attrs.caption ?? "");
|
|
207
299
|
figure.appendChild(figcaption);
|
|
208
300
|
this.figcaption = figcaption;
|
|
301
|
+
|
|
302
|
+
this.syncImageAttrs(node);
|
|
209
303
|
}
|
|
210
304
|
|
|
211
305
|
// --- ProseMirror NodeView interface ---
|
|
@@ -215,9 +309,7 @@ class ImageNodeView {
|
|
|
215
309
|
this.node = node;
|
|
216
310
|
|
|
217
311
|
// Sync DOM with new attrs
|
|
218
|
-
this.
|
|
219
|
-
this.img.alt = String(node.attrs.alt ?? "");
|
|
220
|
-
this.img.title = String(node.attrs.title ?? "");
|
|
312
|
+
this.syncImageAttrs(node);
|
|
221
313
|
|
|
222
314
|
this.dom.dataset.layout = String(node.attrs.layout || "regular");
|
|
223
315
|
this.layoutBtns.forEach((btn, value) => {
|
|
@@ -259,6 +351,7 @@ class ImageNodeView {
|
|
|
259
351
|
// Let the NodeView handle events on toolbar, caption bar, and their children
|
|
260
352
|
if (target.closest(".tiptap-image-toolbar")) return true;
|
|
261
353
|
if (target.closest(".tiptap-image-caption-bar")) return true;
|
|
354
|
+
if (target.closest(".tiptap-image-missing")) return true;
|
|
262
355
|
return false;
|
|
263
356
|
}
|
|
264
357
|
|
|
@@ -296,6 +389,65 @@ class ImageNodeView {
|
|
|
296
389
|
this.view.dispatch(tr);
|
|
297
390
|
}
|
|
298
391
|
|
|
392
|
+
private syncImageAttrs(node: ProseMirrorNode) {
|
|
393
|
+
const src = String(node.attrs.src ?? "");
|
|
394
|
+
this.img.alt = String(node.attrs.alt ?? "");
|
|
395
|
+
this.img.title = String(node.attrs.title ?? "");
|
|
396
|
+
this.missingSource.textContent = imageSourceHint(src);
|
|
397
|
+
this.openBtn.disabled = !canOpenImageSource(src);
|
|
398
|
+
|
|
399
|
+
if (src !== this.currentSrc) {
|
|
400
|
+
this.currentSrc = src;
|
|
401
|
+
this.setMissing(false);
|
|
402
|
+
this.dom.dataset.loadState = "loading";
|
|
403
|
+
this.img.setAttribute("src", src);
|
|
404
|
+
this.checkCachedImageState();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
this.checkCachedImageState();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private checkCachedImageState() {
|
|
412
|
+
queueMicrotask(() => {
|
|
413
|
+
if (
|
|
414
|
+
!this.img.complete ||
|
|
415
|
+
this.img.getAttribute("src") !== this.currentSrc
|
|
416
|
+
) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
this.setMissing(this.img.naturalWidth === 0);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private setMissing(missing: boolean) {
|
|
424
|
+
if (this.missing === missing) {
|
|
425
|
+
this.dom.dataset.loadState = missing ? "missing" : "loaded";
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
this.missing = missing;
|
|
430
|
+
this.dom.dataset.loadState = missing ? "missing" : "loaded";
|
|
431
|
+
this.dom.dispatchEvent(
|
|
432
|
+
new CustomEvent(INLINE_IMAGE_LOAD_STATE_EVENT, {
|
|
433
|
+
bubbles: true,
|
|
434
|
+
composed: true,
|
|
435
|
+
detail: {
|
|
436
|
+
src: this.currentSrc,
|
|
437
|
+
missing,
|
|
438
|
+
},
|
|
439
|
+
}),
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private deleteNode() {
|
|
444
|
+
const pos = this.getPos();
|
|
445
|
+
if (pos === undefined) return;
|
|
446
|
+
const tr = this.view.state.tr.delete(pos, pos + this.node.nodeSize);
|
|
447
|
+
this.view.dispatch(tr);
|
|
448
|
+
this.view.focus();
|
|
449
|
+
}
|
|
450
|
+
|
|
299
451
|
private handleLink() {
|
|
300
452
|
const current = String(this.node.attrs.href ?? "");
|
|
301
453
|
if (current) {
|
|
@@ -324,6 +476,12 @@ class ImageNodeView {
|
|
|
324
476
|
input.click();
|
|
325
477
|
}
|
|
326
478
|
|
|
479
|
+
private handleOpenOriginal() {
|
|
480
|
+
const src = String(this.node.attrs.src ?? "");
|
|
481
|
+
if (!canOpenImageSource(src)) return;
|
|
482
|
+
window.open(src, "_blank", "noopener,noreferrer");
|
|
483
|
+
}
|
|
484
|
+
|
|
327
485
|
private handleExpand() {
|
|
328
486
|
const lightbox = document.querySelector("jant-media-lightbox") as {
|
|
329
487
|
open: (
|
|
@@ -358,17 +516,36 @@ class ImageNodeView {
|
|
|
358
516
|
}
|
|
359
517
|
}
|
|
360
518
|
|
|
519
|
+
function imageSourceHint(src: string): string {
|
|
520
|
+
const trimmed = src.trim();
|
|
521
|
+
if (!trimmed) return "";
|
|
522
|
+
if (trimmed.length > 140) return `${trimmed.slice(0, 137)}...`;
|
|
523
|
+
return trimmed;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function canOpenImageSource(src: string): boolean {
|
|
527
|
+
const trimmed = src.trim();
|
|
528
|
+
if (!trimmed) return false;
|
|
529
|
+
return /^(https?:|\/|\.\/|\.\.\/|blob:)/i.test(trimmed);
|
|
530
|
+
}
|
|
531
|
+
|
|
361
532
|
// ---------------------------------------------------------------------------
|
|
362
533
|
// Node Extension
|
|
363
534
|
// ---------------------------------------------------------------------------
|
|
364
535
|
|
|
365
|
-
export const ImageNode = Node.create({
|
|
536
|
+
export const ImageNode = Node.create<{ labels?: Partial<ImageNodeLabels> }>({
|
|
366
537
|
name: "image",
|
|
367
538
|
group: "block",
|
|
368
539
|
atom: true,
|
|
369
540
|
selectable: true,
|
|
370
541
|
draggable: true,
|
|
371
542
|
|
|
543
|
+
addOptions() {
|
|
544
|
+
return {
|
|
545
|
+
labels: DEFAULT_IMAGE_NODE_LABELS,
|
|
546
|
+
};
|
|
547
|
+
},
|
|
548
|
+
|
|
372
549
|
addAttributes() {
|
|
373
550
|
return {
|
|
374
551
|
src: { default: "" },
|
|
@@ -514,11 +691,16 @@ export const ImageNode = Node.create({
|
|
|
514
691
|
|
|
515
692
|
addNodeView() {
|
|
516
693
|
return ({ node, view, getPos, editor }) => {
|
|
694
|
+
const labels = {
|
|
695
|
+
...DEFAULT_IMAGE_NODE_LABELS,
|
|
696
|
+
...(this.options.labels ?? {}),
|
|
697
|
+
};
|
|
517
698
|
return new ImageNodeView(
|
|
518
699
|
node,
|
|
519
700
|
view,
|
|
520
701
|
getPos as () => number | undefined,
|
|
521
702
|
editor,
|
|
703
|
+
labels,
|
|
522
704
|
);
|
|
523
705
|
};
|
|
524
706
|
},
|
|
@@ -62,7 +62,11 @@ export const RehostImages = Extension.create<RehostImagesOptions>({
|
|
|
62
62
|
new Plugin({
|
|
63
63
|
key: rehostImagesKey,
|
|
64
64
|
appendTransaction(transactions, _oldState, newState) {
|
|
65
|
-
if (
|
|
65
|
+
if (
|
|
66
|
+
!transactions.some((tr) => tr.docChanged && tr.getMeta("paste"))
|
|
67
|
+
) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
66
70
|
const { shouldRehost, rehost } = options;
|
|
67
71
|
if (!shouldRehost || !rehost) return null;
|
|
68
72
|
|
package/src/client-auth.ts
CHANGED
|
@@ -26,7 +26,6 @@ import "./client/collection-form-bridge.js";
|
|
|
26
26
|
import "./client/components/jant-post-form.js";
|
|
27
27
|
import "./client/post-form-bridge.js";
|
|
28
28
|
import "./client/components/jant-nav-manager.js";
|
|
29
|
-
import "./client/nav-manager-bridge.js";
|
|
30
29
|
import "./client/components/jant-post-menu.js";
|
|
31
30
|
import "./client/collection-page-actions.js";
|
|
32
31
|
import "./client/custom-url-menu.js";
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
1
3
|
import { describe, expect, it } from "vitest";
|
|
2
4
|
import { msg } from "@lingui/core/macro";
|
|
3
5
|
import { createI18n } from "../i18n.js";
|
|
4
6
|
|
|
7
|
+
const CORE_ROOT = resolve(import.meta.dirname, "../../..");
|
|
8
|
+
|
|
9
|
+
function readLocaleFile(path: string): string {
|
|
10
|
+
return readFileSync(resolve(CORE_ROOT, path), "utf8");
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
/**
|
|
6
14
|
* Guards the "public surface stays English under non-English locales" contract.
|
|
7
15
|
*
|
|
@@ -37,4 +45,20 @@ describe("i18n locale fallback", () => {
|
|
|
37
45
|
});
|
|
38
46
|
expect(i18n._(unique)).toBe("Jant fallback canary string");
|
|
39
47
|
});
|
|
48
|
+
|
|
49
|
+
it("keeps Chinese settings format labels concise", () => {
|
|
50
|
+
const zhHansSettings = readLocaleFile(
|
|
51
|
+
"src/i18n/locales/settings/zh-Hans.po",
|
|
52
|
+
);
|
|
53
|
+
const zhHantSettings = readLocaleFile(
|
|
54
|
+
"src/i18n/locales/settings/zh-Hant.po",
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(zhHansSettings).toContain('msgid "Quote"\nmsgstr "引用"');
|
|
58
|
+
expect(zhHantSettings).toContain('msgid "Link"\nmsgstr "連結"');
|
|
59
|
+
expect(zhHantSettings).toContain('msgid "Quote"\nmsgstr "引用"');
|
|
60
|
+
|
|
61
|
+
expect(zhHansSettings).not.toContain("Jant 的帖子格式之一");
|
|
62
|
+
expect(zhHantSettings).not.toContain("Jant 的貼文格式之一");
|
|
63
|
+
});
|
|
40
64
|
});
|