@jant/core 0.6.7 → 0.6.8
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/uploads/cleanup.js +1 -0
- package/dist/{app-L1UPUArB.js → app-9P4rVCe2.js} +338 -117
- package/dist/app-DaxS_Cz-.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-C6peCkkD.css +2 -0
- package/dist/client/_assets/{client-B0MvB2r0.js → client-CXnEhyyv.js} +2 -2
- package/dist/client/_assets/{client-auth-CwwuucF_.js → client-auth-CSItbyU8.js} +357 -355
- package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
- package/dist/{export-DLukCOO3.js → export-Be082J0n.js} +33 -8
- package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
- package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
- package/dist/{github-sync-BtHY2AST.js → github-sync-D1Cw8mOY.js} +3 -3
- package/dist/{github-sync-BeDecPen.js → github-sync-_kPWM4m9.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
- package/package.json +1 -1
- package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
- package/src/client/__tests__/compose-bridge.test.ts +105 -0
- package/src/client/__tests__/hydrate-partial.test.ts +27 -0
- package/src/client/__tests__/note-expand.test.ts +130 -0
- package/src/client/archive-nav.js +2 -1
- package/src/client/audio-player.ts +7 -3
- package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +313 -0
- package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
- package/src/client/components/compose-format-convert.ts +255 -0
- package/src/client/components/compose-types.ts +2 -0
- package/src/client/components/jant-compose-dialog.ts +98 -44
- package/src/client/components/jant-compose-editor.ts +64 -11
- package/src/client/compose-bridge.ts +17 -0
- package/src/client/feed-video-player.ts +1 -1
- package/src/client/hydrate-partial.ts +25 -0
- package/src/client/note-expand.ts +63 -0
- package/src/client.ts +1 -0
- package/src/i18n/locales/public/en.po +41 -0
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +41 -0
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +41 -0
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +12 -12
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +12 -12
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +12 -12
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
- package/src/lib/__tests__/markdown.test.ts +1 -1
- package/src/lib/__tests__/summary.test.ts +87 -0
- package/src/lib/__tests__/timeline.test.ts +48 -1
- package/src/lib/__tests__/tiptap-render.test.ts +4 -4
- package/src/lib/__tests__/url.test.ts +44 -0
- package/src/lib/__tests__/view.test.ts +168 -1
- package/src/lib/navigation.ts +1 -0
- package/src/lib/resolve-config.ts +2 -2
- package/src/lib/summary.ts +42 -3
- package/src/lib/tiptap-render.ts +6 -2
- package/src/lib/upload.ts +2 -2
- package/src/lib/url.ts +41 -0
- package/src/lib/view.ts +102 -40
- package/src/preset.css +7 -1
- package/src/routes/api/internal/__tests__/uploads.test.ts +68 -0
- package/src/routes/api/internal/sites.ts +44 -1
- package/src/routes/api/public/__tests__/archive.test.ts +66 -0
- package/src/routes/api/public/archive.ts +22 -6
- package/src/routes/api/telegram.ts +2 -1
- package/src/routes/dash/custom-urls.tsx +1 -1
- package/src/routes/dash/settings.tsx +8 -5
- package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
- package/src/routes/pages/archive.tsx +116 -20
- package/src/routes/pages/collections.tsx +1 -0
- package/src/services/__tests__/media.test.ts +83 -0
- package/src/services/__tests__/post.test.ts +81 -0
- package/src/services/export-theme/assets/client-site.js +1 -1
- package/src/services/export-theme/styles/main.css +49 -15
- package/src/services/media.ts +31 -1
- package/src/services/post.ts +22 -2
- package/src/services/search.ts +4 -4
- package/src/services/upload-session.ts +18 -0
- package/src/styles/tokens.css +1 -1
- package/src/styles/ui.css +163 -34
- package/src/types/config.ts +1 -1
- package/src/types/props.ts +3 -0
- package/src/ui/compose/ComposeDialog.tsx +13 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
- package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
- package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
- package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
- package/src/ui/feed/NoteCard.tsx +54 -5
- package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
- package/src/ui/pages/ArchivePage.tsx +89 -6
- package/src/ui/pages/CollectionsPage.tsx +7 -1
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
- package/src/ui/shared/CollectionDirectory.tsx +13 -3
- package/src/ui/shared/CollectionsManager.tsx +3 -0
- package/dist/app-C1QgMNRY.js +0 -6
- package/dist/client/_assets/client-BMPMuwvV.css +0 -2
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose format conversion
|
|
3
|
+
*
|
|
4
|
+
* When a post's format changes while editing (note / link / quote), each format
|
|
5
|
+
* stores a different subset of structured fields. This module converts those
|
|
6
|
+
* fields so nothing is silently lost:
|
|
7
|
+
*
|
|
8
|
+
* - **Fold** (when *leaving* a format): any field the target format can't hold is
|
|
9
|
+
* pushed into the body as a visible block (a blockquote, a heading, or a link
|
|
10
|
+
* paragraph) and the field is cleared. This never loses data.
|
|
11
|
+
* - **Extract** (when *entering* a format): only `blockquote → quoteText` is
|
|
12
|
+
* recovered from the body front, which keeps the common `quote → note → quote`
|
|
13
|
+
* round-trip lossless. url/title are not auto-extracted — they stay visible in
|
|
14
|
+
* the body and the author re-fills the field if needed.
|
|
15
|
+
*
|
|
16
|
+
* Pure and DOM-free so it can be unit-tested in isolation. It deep-clones the
|
|
17
|
+
* body it is given and never mutates its input.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { JSONContent } from "@tiptap/core";
|
|
21
|
+
|
|
22
|
+
import type { ComposeFormat } from "./compose-types.js";
|
|
23
|
+
|
|
24
|
+
/** The subset of compose fields that participate in format conversion. */
|
|
25
|
+
export interface ComposeConvertFields {
|
|
26
|
+
title: string;
|
|
27
|
+
url: string;
|
|
28
|
+
quoteText: string;
|
|
29
|
+
quoteAuthor: string;
|
|
30
|
+
showTitle: boolean;
|
|
31
|
+
bodyJson: JSONContent | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Matches an attribution paragraph: `— Author` or `— Author https://source`. */
|
|
35
|
+
const ATTRIBUTION_RE = /^—\s*(.*?)(?:\s+(https?:\/\/\S+))?\s*$/;
|
|
36
|
+
|
|
37
|
+
function cloneDoc(doc: JSONContent | null): JSONContent | null {
|
|
38
|
+
return doc ? (JSON.parse(JSON.stringify(doc)) as JSONContent) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Concatenate all descendant text of a node. */
|
|
42
|
+
function nodeText(node: JSONContent | undefined): string {
|
|
43
|
+
if (!node) return "";
|
|
44
|
+
if (typeof node.text === "string") return node.text;
|
|
45
|
+
if (!Array.isArray(node.content)) return "";
|
|
46
|
+
return node.content.map(nodeText).join("");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function makeHeading(text: string, level = 2): JSONContent {
|
|
50
|
+
return {
|
|
51
|
+
type: "heading",
|
|
52
|
+
attrs: { level },
|
|
53
|
+
content: [{ type: "text", text }],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function makeParagraph(text: string): JSONContent {
|
|
58
|
+
return text
|
|
59
|
+
? { type: "paragraph", content: [{ type: "text", text }] }
|
|
60
|
+
: { type: "paragraph" };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function makeLinkParagraph(url: string): JSONContent {
|
|
64
|
+
return {
|
|
65
|
+
type: "paragraph",
|
|
66
|
+
content: [
|
|
67
|
+
{
|
|
68
|
+
type: "text",
|
|
69
|
+
text: url,
|
|
70
|
+
marks: [{ type: "link", attrs: { href: url } }],
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseAttribution(text: string): {
|
|
77
|
+
author: string;
|
|
78
|
+
url: string | null;
|
|
79
|
+
} {
|
|
80
|
+
const match = ATTRIBUTION_RE.exec(text.trim());
|
|
81
|
+
if (!match) return { author: "", url: null };
|
|
82
|
+
return { author: match[1].trim(), url: match[2] ?? null };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** First link href found anywhere within a node (depth-first), or null. */
|
|
86
|
+
function findLinkHref(node: JSONContent | undefined): string | null {
|
|
87
|
+
if (!node) return null;
|
|
88
|
+
if (Array.isArray(node.marks)) {
|
|
89
|
+
const href = node.marks.find((mark) => mark.type === "link")?.attrs?.href;
|
|
90
|
+
if (typeof href === "string" && href) return href;
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(node.content)) {
|
|
93
|
+
for (const child of node.content) {
|
|
94
|
+
const found = findLinkHref(child);
|
|
95
|
+
if (found) return found;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build the `— Author` attribution paragraph for a folded quote. The author name
|
|
103
|
+
* is linked to the source url so the body stays clean (no raw url) while the
|
|
104
|
+
* `note → quote` round-trip can still recover the url from the link mark. When
|
|
105
|
+
* there is no author, the url itself becomes the link text. Null if neither.
|
|
106
|
+
*/
|
|
107
|
+
function makeAttributionParagraph(
|
|
108
|
+
author: string,
|
|
109
|
+
url: string | null,
|
|
110
|
+
): JSONContent | null {
|
|
111
|
+
const name = author.trim();
|
|
112
|
+
const href = url?.trim() ?? "";
|
|
113
|
+
if (!name && !href) return null;
|
|
114
|
+
|
|
115
|
+
const linked = (text: string): JSONContent => ({
|
|
116
|
+
type: "text",
|
|
117
|
+
text,
|
|
118
|
+
marks: [{ type: "link", attrs: { href } }],
|
|
119
|
+
});
|
|
120
|
+
if (name && href) {
|
|
121
|
+
return {
|
|
122
|
+
type: "paragraph",
|
|
123
|
+
content: [{ type: "text", text: "— " }, linked(name)],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (name) {
|
|
127
|
+
return {
|
|
128
|
+
type: "paragraph",
|
|
129
|
+
content: [{ type: "text", text: `— ${name}` }],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
type: "paragraph",
|
|
134
|
+
content: [{ type: "text", text: "— " }, linked(href)],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function makeBlockquote(
|
|
139
|
+
quoteText: string,
|
|
140
|
+
attribution: JSONContent | null,
|
|
141
|
+
): JSONContent {
|
|
142
|
+
const paragraphs = quoteText.split("\n").map((line) => makeParagraph(line));
|
|
143
|
+
if (attribution) paragraphs.push(attribution);
|
|
144
|
+
return { type: "blockquote", content: paragraphs };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Convert compose fields from one post format to another.
|
|
149
|
+
*
|
|
150
|
+
* @param from - the current format
|
|
151
|
+
* @param to - the target format
|
|
152
|
+
* @param fields - the current field values (not mutated)
|
|
153
|
+
* @returns new field values appropriate for the target format
|
|
154
|
+
* @example
|
|
155
|
+
* // quote → note: the quote becomes a leading blockquote in the body
|
|
156
|
+
* convertComposeFormat("quote", "note", {
|
|
157
|
+
* title: "", url: "", quoteText: "Stay hungry", quoteAuthor: "Jobs",
|
|
158
|
+
* showTitle: false, bodyJson: null,
|
|
159
|
+
* });
|
|
160
|
+
*/
|
|
161
|
+
export function convertComposeFormat(
|
|
162
|
+
from: ComposeFormat,
|
|
163
|
+
to: ComposeFormat,
|
|
164
|
+
fields: ComposeConvertFields,
|
|
165
|
+
): ComposeConvertFields {
|
|
166
|
+
if (from === to) return fields;
|
|
167
|
+
|
|
168
|
+
const out: ComposeConvertFields = { ...fields };
|
|
169
|
+
const body = cloneDoc(fields.bodyJson);
|
|
170
|
+
const content: JSONContent[] = Array.isArray(body?.content)
|
|
171
|
+
? [...body.content]
|
|
172
|
+
: [];
|
|
173
|
+
|
|
174
|
+
// ── Extract: leading bare-link paragraph → url (reverses the link→note
|
|
175
|
+
// fold so link↔note round-trips). Only a "bare" link (text === href) is
|
|
176
|
+
// pulled out, so a labeled link line keeps its label instead of silently
|
|
177
|
+
// losing it. ─────────────────────────────────────────────────────────
|
|
178
|
+
if (to === "link" && out.url === "") {
|
|
179
|
+
const first = content[0];
|
|
180
|
+
const href = findLinkHref(first);
|
|
181
|
+
if (
|
|
182
|
+
href &&
|
|
183
|
+
first?.type === "paragraph" &&
|
|
184
|
+
Array.isArray(first.content) &&
|
|
185
|
+
first.content.length === 1 &&
|
|
186
|
+
nodeText(first) === href
|
|
187
|
+
) {
|
|
188
|
+
out.url = href;
|
|
189
|
+
content.shift();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Extract (focused: blockquote → quoteText only) ──────────────────
|
|
194
|
+
if (
|
|
195
|
+
to === "quote" &&
|
|
196
|
+
out.quoteText === "" &&
|
|
197
|
+
content[0]?.type === "blockquote"
|
|
198
|
+
) {
|
|
199
|
+
const paragraphs = Array.isArray(content[0].content)
|
|
200
|
+
? [...content[0].content]
|
|
201
|
+
: [];
|
|
202
|
+
const last = paragraphs[paragraphs.length - 1];
|
|
203
|
+
const lastText = nodeText(last);
|
|
204
|
+
if (paragraphs.length > 0 && /^—/.test(lastText.trim())) {
|
|
205
|
+
const parsed = parseAttribution(lastText);
|
|
206
|
+
// Prefer the link mark's href (the linked-author form) over a url parsed
|
|
207
|
+
// from plain text (legacy `— Author https://…`).
|
|
208
|
+
const url = findLinkHref(last) ?? parsed.url;
|
|
209
|
+
// When the attribution is url-only, the link text is the url itself —
|
|
210
|
+
// don't mistake it for an author.
|
|
211
|
+
const author = parsed.author === url ? "" : parsed.author;
|
|
212
|
+
if (author) out.quoteAuthor = out.quoteAuthor || author;
|
|
213
|
+
if (url && out.url === "") out.url = url;
|
|
214
|
+
paragraphs.pop();
|
|
215
|
+
}
|
|
216
|
+
out.quoteText = paragraphs.map(nodeText).join("\n").trim();
|
|
217
|
+
content.shift();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Harvest (fold fields the target can't hold into the body) ───────
|
|
221
|
+
const prepend: JSONContent[] = [];
|
|
222
|
+
|
|
223
|
+
// Title → heading (only quote can't hold a title)
|
|
224
|
+
if (to === "quote" && out.title.trim() !== "") {
|
|
225
|
+
prepend.push(makeHeading(out.title.trim()));
|
|
226
|
+
out.title = "";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// quoteText → blockquote (note and link can't hold a quote)
|
|
230
|
+
if (to !== "quote" && out.quoteText.trim() !== "") {
|
|
231
|
+
// For a note, the source url has no home either, so fold it into the
|
|
232
|
+
// attribution. For a link, url maps to link.url and is preserved.
|
|
233
|
+
const foldUrl = to === "note" ? out.url : null;
|
|
234
|
+
prepend.push(
|
|
235
|
+
makeBlockquote(
|
|
236
|
+
out.quoteText,
|
|
237
|
+
makeAttributionParagraph(out.quoteAuthor, foldUrl),
|
|
238
|
+
),
|
|
239
|
+
);
|
|
240
|
+
out.quoteText = "";
|
|
241
|
+
out.quoteAuthor = "";
|
|
242
|
+
if (to === "note") out.url = "";
|
|
243
|
+
} else if (to === "note" && out.url.trim() !== "") {
|
|
244
|
+
// Source was a link (no quote text): notes can't hold a url, so fold it.
|
|
245
|
+
prepend.push(makeLinkParagraph(out.url.trim()));
|
|
246
|
+
out.url = "";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (prepend.length) content.unshift(...prepend);
|
|
250
|
+
|
|
251
|
+
out.bodyJson = content.length ? { type: "doc", content } : null;
|
|
252
|
+
out.showTitle = to === "note" && out.title.trim().length > 0;
|
|
253
|
+
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
getSelectedFirstOrder,
|
|
37
37
|
} from "../collection-picker-order.js";
|
|
38
38
|
import type { JantComposeEditor } from "./jant-compose-editor.js";
|
|
39
|
+
import { convertComposeFormat } from "./compose-format-convert.js";
|
|
39
40
|
import { getMediaCategory } from "../../lib/upload.js";
|
|
40
41
|
import { getSlugValidationIssue } from "../../lib/slug-format.js";
|
|
41
42
|
import { createTiptapEditor } from "../tiptap/create-editor.js";
|
|
@@ -666,7 +667,7 @@ export class JantComposeDialog extends LitElement {
|
|
|
666
667
|
super();
|
|
667
668
|
this.collections = [];
|
|
668
669
|
this.labels = {} as ComposeLabels;
|
|
669
|
-
this.uploadMaxFileSize =
|
|
670
|
+
this.uploadMaxFileSize = 1024;
|
|
670
671
|
this.pageMode = false;
|
|
671
672
|
this.closeHref = "/";
|
|
672
673
|
this.autoRestoreDraft = false;
|
|
@@ -743,13 +744,14 @@ export class JantComposeDialog extends LitElement {
|
|
|
743
744
|
this._scheduleCollectionPickerAutofocus();
|
|
744
745
|
}
|
|
745
746
|
if (
|
|
746
|
-
changed.has("_format") ||
|
|
747
747
|
changed.has("_collectionIds") ||
|
|
748
748
|
changed.has("_slug") ||
|
|
749
749
|
changed.has("_publishedAtInput") ||
|
|
750
750
|
changed.has("_visibility")
|
|
751
751
|
) {
|
|
752
|
-
// Schedule draft auto-save (new-post and edit modes, not draft-load)
|
|
752
|
+
// Schedule draft auto-save (new-post and edit modes, not draft-load).
|
|
753
|
+
// `_format` is intentionally excluded: a bare format switch is exploratory
|
|
754
|
+
// and shouldn't persist a draft on its own (see `_switchFormat`).
|
|
753
755
|
if (!this._draftSourceId) {
|
|
754
756
|
this._scheduleDraftSave();
|
|
755
757
|
}
|
|
@@ -2456,7 +2458,7 @@ export class JantComposeDialog extends LitElement {
|
|
|
2456
2458
|
"jant-compose-editor",
|
|
2457
2459
|
)[this._focusedThreadIndex];
|
|
2458
2460
|
editor?.dispatchEvent(
|
|
2459
|
-
new CustomEvent("jant:
|
|
2461
|
+
new CustomEvent("jant:format-change", {
|
|
2460
2462
|
detail: { format: target },
|
|
2461
2463
|
bubbles: true,
|
|
2462
2464
|
}),
|
|
@@ -3052,6 +3054,14 @@ export class JantComposeDialog extends LitElement {
|
|
|
3052
3054
|
const editor = this._editor;
|
|
3053
3055
|
if (!editor) return;
|
|
3054
3056
|
|
|
3057
|
+
// Only persist genuine unsaved changes. Without this, merely opening a post
|
|
3058
|
+
// for edit (or restoring a draft) would write a local draft of the
|
|
3059
|
+
// unchanged content, since loading fires content-change events.
|
|
3060
|
+
if (!this._hasUnsavedChanges()) {
|
|
3061
|
+
globalThis.localStorage.removeItem(this._currentDraftStorageKey());
|
|
3062
|
+
return;
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3055
3065
|
const data = editor.getData();
|
|
3056
3066
|
const hasContent =
|
|
3057
3067
|
!!data.body ||
|
|
@@ -3063,7 +3073,7 @@ export class JantComposeDialog extends LitElement {
|
|
|
3063
3073
|
data.attachedTexts.length > 0;
|
|
3064
3074
|
|
|
3065
3075
|
if (!hasContent) {
|
|
3066
|
-
globalThis.localStorage.removeItem(
|
|
3076
|
+
globalThis.localStorage.removeItem(this._currentDraftStorageKey());
|
|
3067
3077
|
return;
|
|
3068
3078
|
}
|
|
3069
3079
|
|
|
@@ -3098,7 +3108,7 @@ export class JantComposeDialog extends LitElement {
|
|
|
3098
3108
|
|
|
3099
3109
|
try {
|
|
3100
3110
|
globalThis.localStorage.setItem(
|
|
3101
|
-
|
|
3111
|
+
this._currentDraftStorageKey(),
|
|
3102
3112
|
JSON.stringify(draft),
|
|
3103
3113
|
);
|
|
3104
3114
|
} catch {
|
|
@@ -3549,10 +3559,43 @@ export class JantComposeDialog extends LitElement {
|
|
|
3549
3559
|
|
|
3550
3560
|
private static readonly _FORMATS: ComposeFormat[] = ["note", "link", "quote"];
|
|
3551
3561
|
|
|
3562
|
+
/**
|
|
3563
|
+
* Whether a format switch should convert fields (fold/extract). Only when
|
|
3564
|
+
* editing an existing post or a server draft — for a brand-new post, switching
|
|
3565
|
+
* just hides/shows fields and nothing is persisted yet, so conversion would
|
|
3566
|
+
* pollute the body for no benefit.
|
|
3567
|
+
*/
|
|
3568
|
+
private _shouldConvertOnFormatSwitch(): boolean {
|
|
3569
|
+
return !!(this._editPostId || this._draftSourceId);
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3552
3572
|
private _switchFormat(target: ComposeFormat) {
|
|
3553
3573
|
if (this._format === target) return;
|
|
3554
|
-
|
|
3574
|
+
const editor = this._editor;
|
|
3575
|
+
if (editor && this._shouldConvertOnFormatSwitch()) {
|
|
3576
|
+
// Fold fields the target can't hold into the body before the format
|
|
3577
|
+
// change recreates the editor from `_bodyJson`. Synchronous, so the old
|
|
3578
|
+
// Tiptap instance can't fire onUpdate and clobber what we just wrote.
|
|
3579
|
+
// `applyConvertedFields` suppresses the one content-change event the
|
|
3580
|
+
// conversion emits, so the switch itself never schedules a draft save.
|
|
3581
|
+
editor.applyConvertedFields(
|
|
3582
|
+
convertComposeFormat(
|
|
3583
|
+
this._format,
|
|
3584
|
+
target,
|
|
3585
|
+
editor.getConvertibleFields(),
|
|
3586
|
+
),
|
|
3587
|
+
);
|
|
3588
|
+
}
|
|
3589
|
+
// A bare format switch shouldn't persist a local draft, so drop any save
|
|
3590
|
+
// already pending from loading the post.
|
|
3591
|
+
this._cancelDraftSaveTimer();
|
|
3555
3592
|
this._format = target;
|
|
3593
|
+
// Sync the editor's format this tick. Lit commits the `.format` binding
|
|
3594
|
+
// only after this render returns, so `_canPublish()` (which reads
|
|
3595
|
+
// `editor.getData()`, keyed on the editor's format) would otherwise compute
|
|
3596
|
+
// against the stale format for one render and leave the submit button
|
|
3597
|
+
// wrongly disabled.
|
|
3598
|
+
if (editor) editor.format = target;
|
|
3556
3599
|
this._showPublishPanel = false;
|
|
3557
3600
|
if (this._shouldAutofocusFormatInput()) {
|
|
3558
3601
|
globalThis.requestAnimationFrame(() => this._editor?.focusInput());
|
|
@@ -3571,6 +3614,16 @@ export class JantComposeDialog extends LitElement {
|
|
|
3571
3614
|
const draftButtonLabel = this._hasContent()
|
|
3572
3615
|
? this.labels.saveAsDraft
|
|
3573
3616
|
: this.labels.drafts;
|
|
3617
|
+
// Format selector sits inline (above each post) whenever more than one post
|
|
3618
|
+
// is on screen — a reply (parent shown above) or a multi-post thread. The
|
|
3619
|
+
// header then shows a plain title instead of the format selector.
|
|
3620
|
+
const isReply = !!(this._replyToId && this._replyToData);
|
|
3621
|
+
const showTitle = isReply || this._threadItems.length > 0;
|
|
3622
|
+
const headerTitle = this._editPostId
|
|
3623
|
+
? this.labels.editTitle
|
|
3624
|
+
: isReply
|
|
3625
|
+
? this.labels.replyTitle
|
|
3626
|
+
: this.labels.newThread;
|
|
3574
3627
|
|
|
3575
3628
|
return html`
|
|
3576
3629
|
<header class="compose-dialog-header">
|
|
@@ -3583,39 +3636,33 @@ export class JantComposeDialog extends LitElement {
|
|
|
3583
3636
|
</button>
|
|
3584
3637
|
|
|
3585
3638
|
<div class="compose-dialog-header-center">
|
|
3586
|
-
${
|
|
3587
|
-
? html`<span class="compose-dialog-title"
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
"
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
${formatLabels[f]}
|
|
3614
|
-
</button>
|
|
3615
|
-
`,
|
|
3616
|
-
)}
|
|
3617
|
-
</div>
|
|
3618
|
-
`}
|
|
3639
|
+
${showTitle
|
|
3640
|
+
? html`<span class="compose-dialog-title">${headerTitle}</span>`
|
|
3641
|
+
: html`
|
|
3642
|
+
<div class="compose-segmented">
|
|
3643
|
+
<div
|
|
3644
|
+
class=${classMap({
|
|
3645
|
+
"compose-format-pill": true,
|
|
3646
|
+
"compose-format-pill-link": this._format === "link",
|
|
3647
|
+
"compose-format-pill-quote": this._format === "quote",
|
|
3648
|
+
})}
|
|
3649
|
+
></div>
|
|
3650
|
+
${formats.map(
|
|
3651
|
+
(f) => html`
|
|
3652
|
+
<button
|
|
3653
|
+
type="button"
|
|
3654
|
+
class=${classMap({
|
|
3655
|
+
"compose-segmented-item": true,
|
|
3656
|
+
"compose-segmented-item-active": this._format === f,
|
|
3657
|
+
})}
|
|
3658
|
+
@click=${() => this._switchFormat(f)}
|
|
3659
|
+
>
|
|
3660
|
+
${formatLabels[f]}
|
|
3661
|
+
</button>
|
|
3662
|
+
`,
|
|
3663
|
+
)}
|
|
3664
|
+
</div>
|
|
3665
|
+
`}
|
|
3619
3666
|
</div>
|
|
3620
3667
|
|
|
3621
3668
|
<div class="compose-dialog-header-actions">
|
|
@@ -5297,9 +5344,7 @@ export class JantComposeDialog extends LitElement {
|
|
|
5297
5344
|
@focusin=${() => {
|
|
5298
5345
|
this._focusedThreadIndex = index;
|
|
5299
5346
|
}}
|
|
5300
|
-
@jant:
|
|
5301
|
-
e: CustomEvent<{ format: ComposeFormat }>,
|
|
5302
|
-
) => {
|
|
5347
|
+
@jant:format-change=${(e: CustomEvent<{ format: ComposeFormat }>) => {
|
|
5303
5348
|
e.stopPropagation();
|
|
5304
5349
|
this._threadItems = this._threadItems.map((it, i) =>
|
|
5305
5350
|
i === index ? { ...it, format: e.detail.format } : it,
|
|
@@ -5423,6 +5468,7 @@ export class JantComposeDialog extends LitElement {
|
|
|
5423
5468
|
.format=${this._format}
|
|
5424
5469
|
.labels=${this.labels}
|
|
5425
5470
|
.uploadMaxFileSize=${this.uploadMaxFileSize}
|
|
5471
|
+
.inlineFormat=${isReply}
|
|
5426
5472
|
.slashCommandDiscovered=${this.slashCommandDiscovered}
|
|
5427
5473
|
></jant-compose-editor>`;
|
|
5428
5474
|
|
|
@@ -5446,7 +5492,15 @@ export class JantComposeDialog extends LitElement {
|
|
|
5446
5492
|
? html`
|
|
5447
5493
|
<div class="compose-thread-layout">
|
|
5448
5494
|
${this._renderReplyContext()}
|
|
5449
|
-
<div
|
|
5495
|
+
<div
|
|
5496
|
+
class="compose-editor-row"
|
|
5497
|
+
@jant:format-change=${(
|
|
5498
|
+
e: CustomEvent<{ format: ComposeFormat }>,
|
|
5499
|
+
) => {
|
|
5500
|
+
e.stopPropagation();
|
|
5501
|
+
this._switchFormat(e.detail.format);
|
|
5502
|
+
}}
|
|
5503
|
+
>
|
|
5450
5504
|
<div class="compose-thread-dot"></div>
|
|
5451
5505
|
${editor}
|
|
5452
5506
|
</div>
|
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
ComposeEditorSelection,
|
|
31
31
|
ComposeFullscreenOpenDetail,
|
|
32
32
|
} from "./compose-types.js";
|
|
33
|
+
import type { ComposeConvertFields } from "./compose-format-convert.js";
|
|
33
34
|
import {
|
|
34
35
|
UPLOAD_ACCEPT,
|
|
35
36
|
getMediaCategory,
|
|
@@ -178,6 +179,7 @@ export class JantComposeEditor extends LitElement {
|
|
|
178
179
|
uploadMaxFileSize: { type: Number },
|
|
179
180
|
threadItem: { type: Boolean, attribute: "thread-item" },
|
|
180
181
|
removable: { type: Boolean },
|
|
182
|
+
inlineFormat: { type: Boolean, attribute: "inline-format" },
|
|
181
183
|
slashCommandDiscovered: { type: Boolean },
|
|
182
184
|
_title: { state: true },
|
|
183
185
|
_bodyJson: { state: true },
|
|
@@ -203,6 +205,7 @@ export class JantComposeEditor extends LitElement {
|
|
|
203
205
|
declare uploadMaxFileSize: number;
|
|
204
206
|
declare threadItem: boolean;
|
|
205
207
|
declare removable: boolean;
|
|
208
|
+
declare inlineFormat: boolean;
|
|
206
209
|
declare slashCommandDiscovered: boolean;
|
|
207
210
|
declare _title: string;
|
|
208
211
|
declare _bodyJson: JSONContent | null;
|
|
@@ -234,6 +237,14 @@ export class JantComposeEditor extends LitElement {
|
|
|
234
237
|
private _scrollBufferApplied = false;
|
|
235
238
|
private _filePickerCleanup: (() => void) | null = null;
|
|
236
239
|
private _suppressAttachedTextOpenUntil = 0;
|
|
240
|
+
/**
|
|
241
|
+
* Set by {@link applyConvertedFields} so the format-conversion content writes
|
|
242
|
+
* don't emit a content-changed event (which would schedule a draft autosave).
|
|
243
|
+
* A bare format switch shouldn't persist a local draft — see `_switchFormat`.
|
|
244
|
+
* Always consumed: a switch also changes `format`, so `updated()` is guaranteed
|
|
245
|
+
* to run this cycle.
|
|
246
|
+
*/
|
|
247
|
+
private _suppressContentChangedOnce = false;
|
|
237
248
|
#inlineImageUploadGeneration = 0;
|
|
238
249
|
#inlineImageUploadPromises = new Set<Promise<void>>();
|
|
239
250
|
#sortable: { destroy(): void } | null = null;
|
|
@@ -247,9 +258,10 @@ export class JantComposeEditor extends LitElement {
|
|
|
247
258
|
super();
|
|
248
259
|
this.format = "note";
|
|
249
260
|
this.labels = {} as ComposeLabels;
|
|
250
|
-
this.uploadMaxFileSize =
|
|
261
|
+
this.uploadMaxFileSize = 1024;
|
|
251
262
|
this.threadItem = false;
|
|
252
263
|
this.removable = false;
|
|
264
|
+
this.inlineFormat = false;
|
|
253
265
|
this.slashCommandDiscovered = false;
|
|
254
266
|
this._title = "";
|
|
255
267
|
this._bodyJson = null;
|
|
@@ -903,13 +915,19 @@ export class JantComposeEditor extends LitElement {
|
|
|
903
915
|
}
|
|
904
916
|
}
|
|
905
917
|
|
|
906
|
-
// Notify parent dialog of content changes for draft auto-save
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
918
|
+
// Notify parent dialog of content changes for draft auto-save. A format
|
|
919
|
+
// conversion writes content fields too, but it's not a user edit, so skip
|
|
920
|
+
// the notification once when asked.
|
|
921
|
+
if (this._suppressContentChangedOnce) {
|
|
922
|
+
this._suppressContentChangedOnce = false;
|
|
923
|
+
} else {
|
|
924
|
+
for (const key of changed.keys()) {
|
|
925
|
+
if (JantComposeEditor._CONTENT_PROPS.has(key as string)) {
|
|
926
|
+
this.dispatchEvent(
|
|
927
|
+
new Event("jant:compose-content-changed", { bubbles: true }),
|
|
928
|
+
);
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
913
931
|
}
|
|
914
932
|
}
|
|
915
933
|
}
|
|
@@ -924,6 +942,39 @@ export class JantComposeEditor extends LitElement {
|
|
|
924
942
|
};
|
|
925
943
|
}
|
|
926
944
|
|
|
945
|
+
/**
|
|
946
|
+
* Raw field values for format conversion. Unlike {@link getData}, this returns
|
|
947
|
+
* every field regardless of the current format (so a hidden quote/url survives
|
|
948
|
+
* a switch) plus the freshest, normalized body.
|
|
949
|
+
*/
|
|
950
|
+
getConvertibleFields(): ComposeConvertFields {
|
|
951
|
+
return {
|
|
952
|
+
title: this._title,
|
|
953
|
+
url: this._url,
|
|
954
|
+
quoteText: this._quoteText,
|
|
955
|
+
quoteAuthor: this._quoteAuthor,
|
|
956
|
+
showTitle: this._showTitle,
|
|
957
|
+
bodyJson: this._normalizeDocJson(
|
|
958
|
+
this._editor?.getJSON() ?? this._bodyJson,
|
|
959
|
+
),
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Write back fields produced by `convertComposeFormat`. The body is applied via
|
|
965
|
+
* `_bodyJson` only — the imminent format change recreates the editor from it, so
|
|
966
|
+
* calling `setContent` here would be redundant.
|
|
967
|
+
*/
|
|
968
|
+
applyConvertedFields(fields: ComposeConvertFields): void {
|
|
969
|
+
this._suppressContentChangedOnce = true;
|
|
970
|
+
this._title = fields.title;
|
|
971
|
+
this._url = fields.url;
|
|
972
|
+
this._quoteText = fields.quoteText;
|
|
973
|
+
this._quoteAuthor = fields.quoteAuthor;
|
|
974
|
+
this._showTitle = fields.showTitle;
|
|
975
|
+
this._bodyJson = fields.bodyJson;
|
|
976
|
+
}
|
|
977
|
+
|
|
927
978
|
/** Pre-fill all fields for edit mode or draft restore */
|
|
928
979
|
populate(data: {
|
|
929
980
|
format: string;
|
|
@@ -2433,7 +2484,7 @@ export class JantComposeEditor extends LitElement {
|
|
|
2433
2484
|
`;
|
|
2434
2485
|
}
|
|
2435
2486
|
|
|
2436
|
-
private
|
|
2487
|
+
private _renderFormatHeader() {
|
|
2437
2488
|
const formatLabels: Record<ComposeFormat, string> = {
|
|
2438
2489
|
note: this.labels.note,
|
|
2439
2490
|
link: this.labels.link,
|
|
@@ -2463,7 +2514,7 @@ export class JantComposeEditor extends LitElement {
|
|
|
2463
2514
|
@click=${() => {
|
|
2464
2515
|
if (this.format !== f) {
|
|
2465
2516
|
this.dispatchEvent(
|
|
2466
|
-
new CustomEvent("jant:
|
|
2517
|
+
new CustomEvent("jant:format-change", {
|
|
2467
2518
|
bubbles: true,
|
|
2468
2519
|
detail: { format: f },
|
|
2469
2520
|
}),
|
|
@@ -2518,7 +2569,9 @@ export class JantComposeEditor extends LitElement {
|
|
|
2518
2569
|
|
|
2519
2570
|
render() {
|
|
2520
2571
|
return html`
|
|
2521
|
-
${this.threadItem
|
|
2572
|
+
${this.threadItem || this.inlineFormat
|
|
2573
|
+
? this._renderFormatHeader()
|
|
2574
|
+
: nothing}
|
|
2522
2575
|
<section class="compose-body">
|
|
2523
2576
|
${this.format === "note"
|
|
2524
2577
|
? this._renderNoteFields()
|