@jant/core 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app-73ovSon0.js +6 -0
- package/dist/{app-B9XQDSoB.js → app-FtJ5R8pi.js} +46 -63
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-BIA0Kx3b.js +272 -0
- package/dist/client/_assets/client-BbJ0FhON.css +2 -0
- package/dist/client/_assets/{client-auth-DFDajqqT.js → client-auth-LyAV-zh7.js} +82 -82
- package/dist/{export-ZBlfKSKm.js → export-1DCaq4BR.js} +2 -2
- package/dist/{github-sync-bL1hnx3Q.js → github-sync-BWIpO72V.js} +1 -1
- package/dist/{github-sync-C593r22F.js → github-sync-BoYWQKCr.js} +2 -2
- package/dist/index.js +3 -3
- package/dist/node.js +4 -4
- package/package.json +1 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +1 -4
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +1 -1
- package/src/client/components/__tests__/jant-media-lightbox.test.ts +89 -0
- package/src/client/components/compose-types.ts +6 -1
- package/src/client/components/jant-compose-dialog.ts +2 -0
- package/src/client/components/jant-compose-editor.ts +2 -1
- package/src/client/components/jant-media-lightbox.ts +53 -13
- package/src/client/compose-bridge.ts +83 -25
- package/src/client/compose-launch.ts +0 -13
- package/src/client/thread-context.ts +1 -140
- package/src/client/upload-session.ts +77 -31
- package/src/i18n/locales/public/en.po +0 -4
- package/src/i18n/locales/public/zh-Hans.po +0 -4
- package/src/i18n/locales/public/zh-Hant.po +0 -4
- package/src/services/export-theme/assets/client-site.js +5 -5
- package/src/styles/tokens.css +0 -1
- package/src/styles/ui.css +0 -71
- package/src/ui/feed/ThreadPreview.tsx +34 -65
- package/src/ui/feed/__tests__/thread-preview.test.ts +64 -58
- package/src/ui/feed/thread-preview-state.ts +0 -48
- package/dist/app-CHW6VVQt.js +0 -6
- package/dist/client/_assets/client-BoUn7xBo.css +0 -2
- package/dist/client/_assets/client-dSfWfMe9.js +0 -272
|
@@ -14,7 +14,12 @@ export interface ComposeAttachment {
|
|
|
14
14
|
clientId: string;
|
|
15
15
|
file: File;
|
|
16
16
|
previewUrl: string;
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Poster URL for video attachments, used as `<video poster>` so Safari shows
|
|
19
|
+
* a preview frame (Chrome renders the first frame natively, Safari does not).
|
|
20
|
+
* For new uploads this is a blob URL produced by `URL.createObjectURL`; for
|
|
21
|
+
* edit mode it's the server-side poster URL.
|
|
22
|
+
*/
|
|
18
23
|
posterUrl: string | null;
|
|
19
24
|
status: "pending" | "processing" | "uploading" | "done" | "error";
|
|
20
25
|
progress: number | null;
|
|
@@ -66,6 +66,7 @@ interface ApiMediaAttachment {
|
|
|
66
66
|
type: "media";
|
|
67
67
|
id: string;
|
|
68
68
|
previewUrl: string;
|
|
69
|
+
posterUrl?: string | null;
|
|
69
70
|
alt?: string;
|
|
70
71
|
mimeType: string;
|
|
71
72
|
url?: string;
|
|
@@ -429,6 +430,7 @@ async function resolveApiAttachments(allAttachments: ApiAttachment[]) {
|
|
|
429
430
|
const media = mediaItems.map((m) => ({
|
|
430
431
|
id: m.id,
|
|
431
432
|
previewUrl: m.previewUrl,
|
|
433
|
+
posterUrl: m.posterUrl ?? null,
|
|
432
434
|
alt: m.alt,
|
|
433
435
|
mimeType: m.mimeType,
|
|
434
436
|
originalName: m.originalName,
|
|
@@ -837,6 +837,7 @@ export class JantComposeEditor extends LitElement {
|
|
|
837
837
|
media?: Array<{
|
|
838
838
|
id: string;
|
|
839
839
|
previewUrl: string;
|
|
840
|
+
posterUrl?: string | null;
|
|
840
841
|
alt?: string;
|
|
841
842
|
mimeType: string;
|
|
842
843
|
originalName?: string;
|
|
@@ -884,7 +885,7 @@ export class JantComposeEditor extends LitElement {
|
|
|
884
885
|
clientId: randomUUID(),
|
|
885
886
|
file: new File([], m.originalName ?? "existing", { type: m.mimeType }),
|
|
886
887
|
previewUrl: m.previewUrl,
|
|
887
|
-
posterUrl: null,
|
|
888
|
+
posterUrl: m.posterUrl ?? null,
|
|
888
889
|
status: "done" as const,
|
|
889
890
|
progress: null,
|
|
890
891
|
mediaId: m.id,
|
|
@@ -197,9 +197,7 @@ export class JantMediaLightbox extends LitElement {
|
|
|
197
197
|
this.updateComplete.then(() => {
|
|
198
198
|
const dialog = this.querySelector<HTMLDialogElement>(".media-lightbox");
|
|
199
199
|
dialog?.showModal();
|
|
200
|
-
|
|
201
|
-
// the close button, which would show a focus ring on arrow-key nav.
|
|
202
|
-
this.querySelector<HTMLElement>(".media-lightbox-content")?.focus();
|
|
200
|
+
this.#focusCurrentMedia();
|
|
203
201
|
});
|
|
204
202
|
}
|
|
205
203
|
|
|
@@ -285,18 +283,41 @@ export class JantMediaLightbox extends LitElement {
|
|
|
285
283
|
if (ke.key === "Escape") {
|
|
286
284
|
e.preventDefault();
|
|
287
285
|
this.close();
|
|
288
|
-
} else if (
|
|
289
|
-
target?.classList.contains("media-lightbox-short-progress") &&
|
|
290
|
-
(ke.key === "ArrowLeft" || ke.key === "ArrowRight")
|
|
291
|
-
) {
|
|
292
286
|
return;
|
|
293
|
-
} else if (ke.key === "ArrowLeft") {
|
|
294
|
-
e.preventDefault();
|
|
295
|
-
this.#prev();
|
|
296
|
-
} else if (ke.key === "ArrowRight") {
|
|
297
|
-
e.preventDefault();
|
|
298
|
-
this.#next();
|
|
299
287
|
}
|
|
288
|
+
if (ke.key !== "ArrowLeft" && ke.key !== "ArrowRight") return;
|
|
289
|
+
|
|
290
|
+
// Let the progress slider's native arrow-key seeking through.
|
|
291
|
+
if (target?.classList.contains("media-lightbox-short-progress")) return;
|
|
292
|
+
|
|
293
|
+
// On videos, arrow keys scrub the playhead. Item switching happens via
|
|
294
|
+
// the on-screen prev/next buttons — matches YouTube/native player conventions.
|
|
295
|
+
const currentImage = this._images[this._currentIndex];
|
|
296
|
+
if (currentImage?.mimeType?.startsWith("video/")) {
|
|
297
|
+
const video = this.querySelector<HTMLVideoElement>(
|
|
298
|
+
".media-lightbox-video",
|
|
299
|
+
);
|
|
300
|
+
if (video) {
|
|
301
|
+
e.preventDefault();
|
|
302
|
+
const step = 5;
|
|
303
|
+
const delta = ke.key === "ArrowLeft" ? -step : step;
|
|
304
|
+
const duration =
|
|
305
|
+
Number.isFinite(video.duration) && video.duration > 0
|
|
306
|
+
? video.duration
|
|
307
|
+
: null;
|
|
308
|
+
const nextTime =
|
|
309
|
+
duration != null
|
|
310
|
+
? Math.max(0, Math.min(video.currentTime + delta, duration))
|
|
311
|
+
: Math.max(0, video.currentTime + delta);
|
|
312
|
+
video.currentTime = nextTime;
|
|
313
|
+
this._videoCurrentTime = nextTime;
|
|
314
|
+
}
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
e.preventDefault();
|
|
319
|
+
if (ke.key === "ArrowLeft") this.#prev();
|
|
320
|
+
else this.#next();
|
|
300
321
|
};
|
|
301
322
|
|
|
302
323
|
#handleDialogClick = (e: Event) => {
|
|
@@ -344,6 +365,24 @@ export class JantMediaLightbox extends LitElement {
|
|
|
344
365
|
this.querySelector<HTMLVideoElement>(".media-lightbox-video")?.pause();
|
|
345
366
|
}
|
|
346
367
|
|
|
368
|
+
// Focus the active video so space/native shortcuts work without an extra
|
|
369
|
+
// click. Falls back to the content wrapper for images — focusing the close
|
|
370
|
+
// button would show a focus ring during arrow-key nav.
|
|
371
|
+
#focusCurrentMedia() {
|
|
372
|
+
const currentImage = this._images[this._currentIndex];
|
|
373
|
+
const isVideo = currentImage?.mimeType?.startsWith("video/");
|
|
374
|
+
if (isVideo) {
|
|
375
|
+
const video = this.querySelector<HTMLVideoElement>(
|
|
376
|
+
".media-lightbox-video",
|
|
377
|
+
);
|
|
378
|
+
if (video) {
|
|
379
|
+
video.focus();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
this.querySelector<HTMLElement>(".media-lightbox-content")?.focus();
|
|
384
|
+
}
|
|
385
|
+
|
|
347
386
|
#resetShortVideoState(image?: LightboxImage) {
|
|
348
387
|
this._videoCurrentTime = 0;
|
|
349
388
|
this._videoDuration =
|
|
@@ -416,6 +455,7 @@ export class JantMediaLightbox extends LitElement {
|
|
|
416
455
|
stage.scrollTop = 0;
|
|
417
456
|
stage.scrollLeft = 0;
|
|
418
457
|
this.#syncCurrentVideo();
|
|
458
|
+
this.#focusCurrentMedia();
|
|
419
459
|
}
|
|
420
460
|
|
|
421
461
|
render() {
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import {
|
|
22
22
|
showToast,
|
|
23
23
|
showPersistentToast,
|
|
24
|
+
updateToast,
|
|
24
25
|
replaceWithAutoClose,
|
|
25
26
|
queueToastForNextPage,
|
|
26
27
|
} from "./toast.js";
|
|
@@ -28,7 +29,6 @@ import { openReplyForArticle } from "./compose-launch.js";
|
|
|
28
29
|
import { getJsonString, readJsonObject } from "./json.js";
|
|
29
30
|
import { uploadViaSession } from "./upload-session.js";
|
|
30
31
|
import { publicPath } from "./runtime-paths.js";
|
|
31
|
-
import { setupThreadContexts } from "./thread-context.js";
|
|
32
32
|
import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
|
|
33
33
|
import { getMediaCategory } from "../lib/upload.js";
|
|
34
34
|
import { resolveInlineImageUrls } from "./tiptap/inline-image-upload.js";
|
|
@@ -75,7 +75,6 @@ async function refreshTimelineThreadView(
|
|
|
75
75
|
if (!html) return false;
|
|
76
76
|
|
|
77
77
|
content.innerHTML = html;
|
|
78
|
-
setupThreadContexts(content);
|
|
79
78
|
return true;
|
|
80
79
|
} catch {
|
|
81
80
|
return false;
|
|
@@ -98,7 +97,6 @@ async function refreshPostCardView(postId: string): Promise<boolean> {
|
|
|
98
97
|
);
|
|
99
98
|
if (!content) return false;
|
|
100
99
|
content.innerHTML = html;
|
|
101
|
-
setupThreadContexts(content);
|
|
102
100
|
return true;
|
|
103
101
|
}
|
|
104
102
|
|
|
@@ -108,12 +106,6 @@ async function refreshPostCardView(postId: string): Promise<boolean> {
|
|
|
108
106
|
if (!article) return false;
|
|
109
107
|
|
|
110
108
|
article.outerHTML = html;
|
|
111
|
-
const refreshed = document.querySelector<HTMLElement>(
|
|
112
|
-
`article[data-post-id="${postId}"]`,
|
|
113
|
-
);
|
|
114
|
-
if (refreshed) {
|
|
115
|
-
setupThreadContexts(refreshed);
|
|
116
|
-
}
|
|
117
109
|
return true;
|
|
118
110
|
} catch {
|
|
119
111
|
return false;
|
|
@@ -133,12 +125,6 @@ async function refreshPostPageView(postId: string): Promise<boolean> {
|
|
|
133
125
|
if (!html) return false;
|
|
134
126
|
|
|
135
127
|
container.outerHTML = html;
|
|
136
|
-
const refreshed = document.querySelector<HTMLElement>(
|
|
137
|
-
`[data-post-view][data-post-view-id="${postId}"]`,
|
|
138
|
-
);
|
|
139
|
-
if (refreshed) {
|
|
140
|
-
setupThreadContexts(refreshed);
|
|
141
|
-
}
|
|
142
128
|
return true;
|
|
143
129
|
} catch {
|
|
144
130
|
return false;
|
|
@@ -173,6 +159,46 @@ const uploadPromises = new Map<string, Promise<string | null>>();
|
|
|
173
159
|
/** Track attachments removed while their upload is still in flight */
|
|
174
160
|
const removedClientIds = new Set<string>();
|
|
175
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Track upload-phase progress (0..1) per clientId for live toast aggregation.
|
|
164
|
+
* Populated only during the actual byte-upload phase — processing/transcoding
|
|
165
|
+
* progress is intentionally excluded so the percentage doesn't reset mid-flight.
|
|
166
|
+
*/
|
|
167
|
+
const uploadProgress = new Map<string, number>();
|
|
168
|
+
|
|
169
|
+
interface ActiveUploadToast {
|
|
170
|
+
clientIds: string[];
|
|
171
|
+
baseMsg: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let activeUploadToast: ActiveUploadToast | null = null;
|
|
175
|
+
|
|
176
|
+
function refreshUploadToast() {
|
|
177
|
+
if (!activeUploadToast) return;
|
|
178
|
+
const { clientIds, baseMsg } = activeUploadToast;
|
|
179
|
+
if (clientIds.length === 0) return;
|
|
180
|
+
|
|
181
|
+
let sum = 0;
|
|
182
|
+
let done = 0;
|
|
183
|
+
for (const id of clientIds) {
|
|
184
|
+
const p = uploadProgress.get(id) ?? 0;
|
|
185
|
+
sum += Math.min(1, Math.max(0, p));
|
|
186
|
+
if (p >= 1) done += 1;
|
|
187
|
+
}
|
|
188
|
+
const total = clientIds.length;
|
|
189
|
+
const pct = Math.floor((sum / total) * 100);
|
|
190
|
+
// Show "currently on item N of M" rather than "N completed of M": while any
|
|
191
|
+
// work is in flight we report the next-in-progress index (capped at total),
|
|
192
|
+
// so 2 files with neither done shows "1/2" instead of the confusing "0/2".
|
|
193
|
+
const current = Math.min(done + 1, total);
|
|
194
|
+
|
|
195
|
+
const message =
|
|
196
|
+
total === 1
|
|
197
|
+
? `${baseMsg} ${pct}%`
|
|
198
|
+
: `${baseMsg} ${pct}% ${current}/${total}`;
|
|
199
|
+
updateToast("compose-deferred", message);
|
|
200
|
+
}
|
|
201
|
+
|
|
176
202
|
/**
|
|
177
203
|
* Track completed upload mediaIds by clientId.
|
|
178
204
|
*
|
|
@@ -265,6 +291,13 @@ async function uploadFile(
|
|
|
265
291
|
clientId: string,
|
|
266
292
|
editor: JantComposeEditor | null,
|
|
267
293
|
): Promise<string | null> {
|
|
294
|
+
// Capture cheap metadata up-front so we can release `file` (the original
|
|
295
|
+
// potentially-huge blob) as soon as transcoding finishes. On iOS Safari
|
|
296
|
+
// holding a 300MB+ source blob alongside the transcoded output, upload
|
|
297
|
+
// chunks, and decoder buffers can push the tab past the per-process
|
|
298
|
+
// memory cap and get it silently reloaded mid-publish.
|
|
299
|
+
const fileType = file.type;
|
|
300
|
+
const fileName = file.name;
|
|
268
301
|
try {
|
|
269
302
|
let toUpload: File;
|
|
270
303
|
let width: number | undefined;
|
|
@@ -274,7 +307,7 @@ async function uploadFile(
|
|
|
274
307
|
let waveform: string | undefined;
|
|
275
308
|
let poster: Blob | undefined;
|
|
276
309
|
|
|
277
|
-
if (
|
|
310
|
+
if (fileType.startsWith("video/")) {
|
|
278
311
|
// Video: transcode with mediabunny (requires WebCodecs)
|
|
279
312
|
if (!VideoProcessor.isSupported()) {
|
|
280
313
|
editor?.updateAttachmentStatus(
|
|
@@ -298,6 +331,8 @@ async function uploadFile(
|
|
|
298
331
|
editor?.updateAttachmentProgress(clientId, progress);
|
|
299
332
|
});
|
|
300
333
|
toUpload = result.file;
|
|
334
|
+
// Drop the original blob ref now that we have the transcoded output.
|
|
335
|
+
file = null as unknown as File;
|
|
301
336
|
width = result.width;
|
|
302
337
|
height = result.height;
|
|
303
338
|
durationSeconds = result.durationSeconds;
|
|
@@ -306,7 +341,7 @@ async function uploadFile(
|
|
|
306
341
|
if (poster) {
|
|
307
342
|
editor?.updateAttachmentPoster(clientId, poster);
|
|
308
343
|
}
|
|
309
|
-
} else if (
|
|
344
|
+
} else if (fileType.startsWith("audio/")) {
|
|
310
345
|
// Audio: transcode to AAC (.m4a) (requires WebCodecs)
|
|
311
346
|
if (!AudioProcessor.isSupported()) {
|
|
312
347
|
editor?.updateAttachmentStatus(
|
|
@@ -330,23 +365,24 @@ async function uploadFile(
|
|
|
330
365
|
editor?.updateAttachmentProgress(clientId, progress);
|
|
331
366
|
});
|
|
332
367
|
toUpload = result.file;
|
|
368
|
+
file = null as unknown as File;
|
|
333
369
|
} else if (
|
|
334
|
-
|
|
335
|
-
/\.heic$/i.test(
|
|
336
|
-
/\.heif$/i.test(
|
|
370
|
+
fileType.startsWith("image/") ||
|
|
371
|
+
/\.heic$/i.test(fileName) ||
|
|
372
|
+
/\.heif$/i.test(fileName)
|
|
337
373
|
) {
|
|
338
374
|
// Image: convert HEIC/HEIF if needed, then resize + convert to WebP
|
|
339
375
|
let imageFile = file;
|
|
340
376
|
try {
|
|
341
377
|
const { isHeic, heicTo } = await import("heic-to");
|
|
342
|
-
if (await isHeic(
|
|
378
|
+
if (await isHeic(imageFile)) {
|
|
343
379
|
editor?.updateAttachmentStatus(clientId, "processing", null, null);
|
|
344
380
|
const blob = await heicTo({
|
|
345
|
-
blob:
|
|
381
|
+
blob: imageFile,
|
|
346
382
|
type: "image/jpeg",
|
|
347
383
|
quality: 0.92,
|
|
348
384
|
});
|
|
349
|
-
imageFile = new File([blob],
|
|
385
|
+
imageFile = new File([blob], fileName.replace(/\.heic$/i, ".jpg"), {
|
|
350
386
|
type: "image/jpeg",
|
|
351
387
|
});
|
|
352
388
|
editor?.updateAttachmentPreview(clientId, imageFile);
|
|
@@ -355,6 +391,8 @@ async function uploadFile(
|
|
|
355
391
|
toUpload = result.file;
|
|
356
392
|
width = result.width;
|
|
357
393
|
height = result.height;
|
|
394
|
+
file = null as unknown as File;
|
|
395
|
+
imageFile = null as unknown as File;
|
|
358
396
|
} catch {
|
|
359
397
|
editor?.removeAttachment(clientId);
|
|
360
398
|
showToast("Image format not supported.", "error");
|
|
@@ -369,7 +407,7 @@ async function uploadFile(
|
|
|
369
407
|
|
|
370
408
|
// Extract metadata for non-video files (video metadata comes from VideoProcessor)
|
|
371
409
|
// Audio waveform is already extracted above (before AudioProcessor runs).
|
|
372
|
-
if (!
|
|
410
|
+
if (!fileType.startsWith("video/")) {
|
|
373
411
|
const meta = await extractMediaMetadata(toUpload);
|
|
374
412
|
width ??= meta.width;
|
|
375
413
|
height ??= meta.height;
|
|
@@ -387,7 +425,7 @@ async function uploadFile(
|
|
|
387
425
|
// markdown via the compose API and materialized by `createTextAttachment`.
|
|
388
426
|
let summary: string | undefined;
|
|
389
427
|
let chars: number | undefined;
|
|
390
|
-
const category = getMediaCategory(
|
|
428
|
+
const category = getMediaCategory(fileType);
|
|
391
429
|
if (category === "text") {
|
|
392
430
|
try {
|
|
393
431
|
const textContent = await toUpload.text();
|
|
@@ -400,6 +438,9 @@ async function uploadFile(
|
|
|
400
438
|
}
|
|
401
439
|
}
|
|
402
440
|
|
|
441
|
+
uploadProgress.set(clientId, 0);
|
|
442
|
+
refreshUploadToast();
|
|
443
|
+
|
|
403
444
|
const result = await uploadViaSession(
|
|
404
445
|
toUpload,
|
|
405
446
|
{
|
|
@@ -414,13 +455,19 @@ async function uploadFile(
|
|
|
414
455
|
},
|
|
415
456
|
(progress) => {
|
|
416
457
|
editor?.updateAttachmentProgress(clientId, progress);
|
|
458
|
+
uploadProgress.set(clientId, progress);
|
|
459
|
+
refreshUploadToast();
|
|
417
460
|
},
|
|
418
461
|
);
|
|
419
462
|
|
|
463
|
+
uploadProgress.set(clientId, 1);
|
|
464
|
+
refreshUploadToast();
|
|
420
465
|
editor?.updateAttachmentStatus(clientId, "done", result.id, null);
|
|
421
466
|
completedMediaIds.set(clientId, result.id);
|
|
422
467
|
return result.id;
|
|
423
468
|
} catch (error) {
|
|
469
|
+
uploadProgress.delete(clientId);
|
|
470
|
+
refreshUploadToast();
|
|
424
471
|
const message = error instanceof Error ? error.message : "Upload failed";
|
|
425
472
|
editor?.updateAttachmentStatus(clientId, "error", null, message);
|
|
426
473
|
// Error is shown on the attachment thumbnail; only toast when there's no editor context.
|
|
@@ -625,10 +672,21 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
|
|
|
625
672
|
// Show persistent toast only when uploads are still in flight
|
|
626
673
|
if (hasPending) {
|
|
627
674
|
showPersistentToast("compose-deferred", uploadingMsg);
|
|
675
|
+
if (detail.pendingAttachments.length > 0) {
|
|
676
|
+
activeUploadToast = {
|
|
677
|
+
clientIds: detail.pendingAttachments.map((a) => a.clientId),
|
|
678
|
+
baseMsg: uploadingMsg,
|
|
679
|
+
};
|
|
680
|
+
refreshUploadToast();
|
|
681
|
+
}
|
|
628
682
|
}
|
|
629
683
|
|
|
630
684
|
/** Show result toast — replaces persistent toast if one exists, otherwise shows a new one */
|
|
631
685
|
const toastMsg = (msg: string, type: "success" | "error" = "success") => {
|
|
686
|
+
if (activeUploadToast) {
|
|
687
|
+
for (const id of activeUploadToast.clientIds) uploadProgress.delete(id);
|
|
688
|
+
activeUploadToast = null;
|
|
689
|
+
}
|
|
632
690
|
if (hasPending) {
|
|
633
691
|
replaceWithAutoClose("compose-deferred", msg, type);
|
|
634
692
|
} else {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { JantComposeDialog } from "./components/jant-compose-dialog.js";
|
|
2
|
-
import type { ComposeFormat } from "./components/compose-types.js";
|
|
3
2
|
|
|
4
3
|
interface ReplyToData {
|
|
5
4
|
contentHtml: string;
|
|
@@ -100,16 +99,6 @@ function getReplyData(article: HTMLElement): ReplyToData {
|
|
|
100
99
|
};
|
|
101
100
|
}
|
|
102
101
|
|
|
103
|
-
function getArticleComposeFormat(
|
|
104
|
-
article: HTMLElement,
|
|
105
|
-
): ComposeFormat | undefined {
|
|
106
|
-
const format = article.dataset.format;
|
|
107
|
-
if (format === "note" || format === "link" || format === "quote") {
|
|
108
|
-
return format;
|
|
109
|
-
}
|
|
110
|
-
return undefined;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
102
|
export async function openNewCompose(
|
|
114
103
|
options?: ComposeOpenOptions,
|
|
115
104
|
): Promise<void> {
|
|
@@ -124,12 +113,10 @@ export async function openReplyForArticle(article: HTMLElement): Promise<void> {
|
|
|
124
113
|
if (!dialog) return;
|
|
125
114
|
|
|
126
115
|
const threadRootId = article.dataset.threadRootId ?? postId;
|
|
127
|
-
const initialFormat = getArticleComposeFormat(article);
|
|
128
116
|
await dialog.openReply(
|
|
129
117
|
postId,
|
|
130
118
|
getReplyData(article),
|
|
131
119
|
threadRootId,
|
|
132
120
|
getReplyRefreshTarget(article) ?? undefined,
|
|
133
|
-
initialFormat ? { initialFormat } : undefined,
|
|
134
121
|
);
|
|
135
122
|
}
|
|
@@ -1,131 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Thread Context Interactions
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 2. Auto-scroll to current post on thread detail pages
|
|
4
|
+
* Auto-scroll to current post on thread detail pages.
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
|
-
function parsePixelValue(value: string, fallback: number): number {
|
|
9
|
-
const parsed = Number.parseFloat(value);
|
|
10
|
-
return Number.isFinite(parsed) ? parsed : fallback;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function getCollapsedMaxHeight(container: HTMLElement): number {
|
|
14
|
-
const wasExpanded = container.classList.contains("expanded");
|
|
15
|
-
if (wasExpanded) {
|
|
16
|
-
container.classList.remove("expanded");
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const value = getComputedStyle(container).maxHeight;
|
|
20
|
-
const maxHeight = parsePixelValue(value, 188);
|
|
21
|
-
|
|
22
|
-
if (wasExpanded) {
|
|
23
|
-
container.classList.add("expanded");
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
return maxHeight;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function getPendingImages(container: HTMLElement): HTMLImageElement[] {
|
|
30
|
-
return Array.from(container.querySelectorAll("img")).filter(
|
|
31
|
-
(image) => !image.complete,
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function waitForContentToSettle(
|
|
36
|
-
container: HTMLElement,
|
|
37
|
-
callback: () => void,
|
|
38
|
-
): void {
|
|
39
|
-
const pendingImages = getPendingImages(container);
|
|
40
|
-
if (pendingImages.length === 0) {
|
|
41
|
-
requestAnimationFrame(() => {
|
|
42
|
-
requestAnimationFrame(callback);
|
|
43
|
-
});
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
let remaining = pendingImages.length;
|
|
48
|
-
const handleDone = (): void => {
|
|
49
|
-
remaining -= 1;
|
|
50
|
-
if (remaining === 0) {
|
|
51
|
-
callback();
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
pendingImages.forEach((image) => {
|
|
56
|
-
image.addEventListener("load", handleDone, { once: true });
|
|
57
|
-
image.addEventListener("error", handleDone, { once: true });
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function updateThreadContextState(
|
|
62
|
-
container: HTMLElement,
|
|
63
|
-
toggle: HTMLElement,
|
|
64
|
-
allowExpand: boolean,
|
|
65
|
-
): void {
|
|
66
|
-
const collapsedMaxHeight = getCollapsedMaxHeight(container);
|
|
67
|
-
const isExpanded = container.classList.contains("expanded");
|
|
68
|
-
const overflows = container.scrollHeight > collapsedMaxHeight + 1;
|
|
69
|
-
const showMoreLabel = toggle.dataset.labelMore ?? "Show more";
|
|
70
|
-
const showLessLabel = toggle.dataset.labelLess ?? "Show less";
|
|
71
|
-
|
|
72
|
-
if (!overflows) {
|
|
73
|
-
if (allowExpand) {
|
|
74
|
-
container.classList.remove(
|
|
75
|
-
"thread-context-collapsed",
|
|
76
|
-
"thread-context-faded",
|
|
77
|
-
"expanded",
|
|
78
|
-
);
|
|
79
|
-
} else {
|
|
80
|
-
container.classList.add("thread-context-collapsed");
|
|
81
|
-
container.classList.remove("thread-context-faded", "expanded");
|
|
82
|
-
}
|
|
83
|
-
toggle.classList.add("hidden");
|
|
84
|
-
toggle.textContent = showMoreLabel;
|
|
85
|
-
toggle.setAttribute("aria-expanded", "false");
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
container.classList.add("thread-context-collapsed");
|
|
90
|
-
container.classList.add("thread-context-faded");
|
|
91
|
-
toggle.classList.remove("hidden");
|
|
92
|
-
toggle.textContent = isExpanded ? showLessLabel : showMoreLabel;
|
|
93
|
-
toggle.setAttribute("aria-expanded", isExpanded ? "true" : "false");
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function setupThreadContext(group: HTMLElement): void {
|
|
97
|
-
const container = group.querySelector<HTMLElement>("[data-thread-context]");
|
|
98
|
-
const toggle = group.querySelector<HTMLElement>(
|
|
99
|
-
"[data-thread-context-toggle]",
|
|
100
|
-
);
|
|
101
|
-
if (!container || !toggle) return;
|
|
102
|
-
|
|
103
|
-
let allowExpand = false;
|
|
104
|
-
updateThreadContextState(container, toggle, allowExpand);
|
|
105
|
-
|
|
106
|
-
waitForContentToSettle(container, () => {
|
|
107
|
-
allowExpand = true;
|
|
108
|
-
updateThreadContextState(container, toggle, allowExpand);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
if ("ResizeObserver" in globalThis) {
|
|
112
|
-
const observer = new globalThis.ResizeObserver(() => {
|
|
113
|
-
updateThreadContextState(container, toggle, allowExpand);
|
|
114
|
-
});
|
|
115
|
-
observer.observe(container);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export function setupThreadContexts(
|
|
120
|
-
root: globalThis.Document | globalThis.Element = document,
|
|
121
|
-
): void {
|
|
122
|
-
root.querySelectorAll(".thread-group").forEach((group) => {
|
|
123
|
-
if (group instanceof HTMLElement) {
|
|
124
|
-
setupThreadContext(group);
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
|
|
129
7
|
function isFirstThreadDetailItem(current: HTMLElement): boolean {
|
|
130
8
|
const group = current.closest<HTMLElement>(".thread-group-detail");
|
|
131
9
|
if (!group) return false;
|
|
@@ -161,25 +39,8 @@ function scrollCurrentDetailPostIntoView(
|
|
|
161
39
|
});
|
|
162
40
|
}
|
|
163
41
|
|
|
164
|
-
// Expand/collapse: event delegation on toggle buttons
|
|
165
|
-
document.addEventListener("click", (e) => {
|
|
166
|
-
const toggle = (e.target as HTMLElement).closest<HTMLElement>(
|
|
167
|
-
"[data-thread-context-toggle]",
|
|
168
|
-
);
|
|
169
|
-
if (!toggle) return;
|
|
170
|
-
|
|
171
|
-
const container = toggle
|
|
172
|
-
.closest(".thread-group")
|
|
173
|
-
?.querySelector<HTMLElement>("[data-thread-context]");
|
|
174
|
-
if (!container) return;
|
|
175
|
-
|
|
176
|
-
container.classList.toggle("expanded");
|
|
177
|
-
updateThreadContextState(container, toggle, true);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
42
|
// Auto-scroll to current post on detail pages
|
|
181
43
|
document.addEventListener("DOMContentLoaded", () => {
|
|
182
|
-
setupThreadContexts(document);
|
|
183
44
|
scrollCurrentDetailPostIntoView(document);
|
|
184
45
|
});
|
|
185
46
|
|