@jant/core 0.6.1 → 0.6.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.
Files changed (49) hide show
  1. package/dist/app-CUZaVgsC.js +6 -0
  2. package/dist/{app-DYQdDMs8.js → app-Ct9c4zYF.js} +296 -50
  3. package/dist/client/.vite/manifest.json +3 -3
  4. package/dist/client/_assets/client-Bp2IPjDe.js +275 -0
  5. package/dist/client/_assets/client-YVrRjAid.css +2 -0
  6. package/dist/client/_assets/{client-auth-CSNcTJwP.js → client-auth-C4hQWqH1.js} +4 -4
  7. package/dist/{env-C7e2Nlnt.js → env-CoSe-1y4.js} +1 -1
  8. package/dist/{export-Bbn86HmS.js → export-O2w3AsZX.js} +4 -4
  9. package/dist/{github-api-Bh0PH3zr.js → github-api-UD4u_7fa.js} +1 -1
  10. package/dist/{github-app-D0GvNnqp.js → github-app-DeX6Td1O.js} +1 -1
  11. package/dist/{github-sync-dXsiZa_e.js → github-sync-BUzIYouS.js} +3 -3
  12. package/dist/{github-sync-CBQPRZ8H.js → github-sync-D49RADci.js} +3 -3
  13. package/dist/index.js +5 -5
  14. package/dist/node.js +6 -6
  15. package/dist/{url-umUptr5z.js → url-XF0GbKGO.js} +22 -1
  16. package/package.json +1 -1
  17. package/src/client/__tests__/image-processor.test.ts +64 -0
  18. package/src/client/components/__tests__/jant-media-lightbox.test.ts +79 -8
  19. package/src/client/components/jant-compose-editor.ts +2 -2
  20. package/src/client/components/jant-media-lightbox.ts +33 -5
  21. package/src/client/image-processor.ts +89 -30
  22. package/src/client/media-scroll-hint.ts +62 -9
  23. package/src/i18n/coverage.generated.ts +2 -2
  24. package/src/i18n/locales/settings/zh-Hans.po +24 -24
  25. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  26. package/src/i18n/locales/settings/zh-Hant.po +24 -24
  27. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  28. package/src/lib/__tests__/structured-data.test.ts +87 -0
  29. package/src/lib/post-display.ts +78 -1
  30. package/src/lib/render.tsx +28 -0
  31. package/src/lib/structured-data.ts +113 -0
  32. package/src/lib/url.ts +26 -0
  33. package/src/routes/api/internal/__tests__/sites.test.ts +65 -0
  34. package/src/routes/api/internal/sites.ts +19 -0
  35. package/src/routes/pages/home.tsx +21 -1
  36. package/src/routes/pages/page.tsx +53 -2
  37. package/src/services/export-theme/assets/client-site.css +1 -1
  38. package/src/services/export-theme/assets/client-site.js +30 -29
  39. package/src/services/export-theme/layouts/partials/media-gallery.html +16 -7
  40. package/src/services/site-admin.ts +53 -1
  41. package/src/styles/site-media.css +70 -24
  42. package/src/styles/ui.css +7 -2
  43. package/src/ui/layouts/BaseLayout.tsx +110 -16
  44. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +146 -0
  45. package/src/ui/shared/MediaGallery.tsx +50 -7
  46. package/src/ui/shared/__tests__/media-gallery.test.ts +31 -0
  47. package/dist/app-CMSW_AYG.js +0 -6
  48. package/dist/client/_assets/client-BRTh1ii1.js +0 -274
  49. package/dist/client/_assets/client-CO4b-RKd.css +0 -2
package/dist/node.js CHANGED
@@ -1,8 +1,8 @@
1
- import "./url-umUptr5z.js";
2
- import { B as isAssetPath, L as buildThemeStyle, R as BUILTIN_COLOR_THEMES, _ as sqliteSchemaBundle, a as resolveDatabaseDialect, c as resolveConfig, d as setWebhook, f as BUILTIN_FONT_THEMES, g as pgSchemaBundle, i as createSiteService, l as getWebhookUrl, m as getFontThemeCssVariables, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as getCjkSerifCssVariables, r as createNodeRequestRuntime, s as createStorageDriver, t as createApp, u as setMyCommands, v as createNodeDatabase, y as schema_exports, z as getPublicAssetBasePath } from "./app-DYQdDMs8.js";
3
- import { t as createExportService } from "./export-Bbn86HmS.js";
4
- import { C as shouldTrustProxy, S as getTelegramWebhookSecret, b as getSiteResolutionMode, d as getHostedControlPlaneBaseUrl, i as getConfiguredSingleSitePathPrefix, l as getEnvString, r as getConfiguredSingleSiteOrigin, x as getTelegramBotPool, y as getPort } from "./env-C7e2Nlnt.js";
5
- import "./github-sync-CBQPRZ8H.js";
1
+ import "./url-XF0GbKGO.js";
2
+ import { B as isAssetPath, L as buildThemeStyle, R as BUILTIN_COLOR_THEMES, _ as sqliteSchemaBundle, a as resolveDatabaseDialect, c as resolveConfig, d as setWebhook, f as BUILTIN_FONT_THEMES, g as pgSchemaBundle, i as createSiteService, l as getWebhookUrl, m as getFontThemeCssVariables, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as getCjkSerifCssVariables, r as createNodeRequestRuntime, s as createStorageDriver, t as createApp, u as setMyCommands, v as createNodeDatabase, y as schema_exports, z as getPublicAssetBasePath } from "./app-Ct9c4zYF.js";
3
+ import { t as createExportService } from "./export-O2w3AsZX.js";
4
+ import { C as shouldTrustProxy, S as getTelegramWebhookSecret, b as getSiteResolutionMode, d as getHostedControlPlaneBaseUrl, i as getConfiguredSingleSitePathPrefix, l as getEnvString, r as getConfiguredSingleSiteOrigin, x as getTelegramBotPool, y as getPort } from "./env-CoSe-1y4.js";
5
+ import "./github-sync-D49RADci.js";
6
6
  import { drizzle } from "drizzle-orm/better-sqlite3";
7
7
  import { serve } from "@hono/node-server";
8
8
  import Database from "better-sqlite3";
@@ -529,7 +529,7 @@ async function createNodeRequestHandler(options) {
529
529
  async function start(env = process.env, app) {
530
530
  const handler = await createNodeRequestHandler({
531
531
  env,
532
- app: async () => app ?? (await import("./app-CMSW_AYG.js")).createApp()
532
+ app: async () => app ?? (await import("./app-CUZaVgsC.js")).createApp()
533
533
  });
534
534
  const hostname = resolveHost(env);
535
535
  const port = resolvePort(env);
@@ -29,6 +29,7 @@ var __exportAll = (all, no_symbols) => {
29
29
  sanitizeUrl: () => sanitizeUrl,
30
30
  slugify: () => slugify,
31
31
  stripSitePathPrefix: () => stripSitePathPrefix,
32
+ toAbsoluteAssetUrl: () => toAbsoluteAssetUrl,
32
33
  toAbsoluteSiteUrl: () => toAbsoluteSiteUrl,
33
34
  toPublicHref: () => toPublicHref,
34
35
  toPublicPath: () => toPublicPath
@@ -330,5 +331,25 @@ var SAFE_URL_PROTOCOLS = new Set([
330
331
  if (!siteUrl) return toPublicPath(path, sitePathPrefix);
331
332
  return new URL(toPublicPath(path, sitePathPrefix), siteUrl).toString();
332
333
  }
334
+ /**
335
+ * Resolve a possibly-relative asset URL to an absolute URL, leaving
336
+ * already-absolute (`http(s):`) and protocol-relative (`//host`) URLs
337
+ * untouched. Use for assets — like media — whose stored URL may be either an
338
+ * app-local path or a full CDN URL.
339
+ *
340
+ * @param url - Asset URL: an internal path or an already-absolute URL
341
+ * @param siteUrl - Normalized site URL
342
+ * @param sitePathPrefix - Public site path prefix
343
+ * @returns Absolute URL, or the original value when it is already absolute
344
+ *
345
+ * @example
346
+ * ```ts
347
+ * toAbsoluteAssetUrl("/m/a.png", "https://site.com"); // "https://site.com/m/a.png"
348
+ * toAbsoluteAssetUrl("https://cdn.example/a.png", "x"); // "https://cdn.example/a.png"
349
+ * ```
350
+ */ function toAbsoluteAssetUrl(url, siteUrl, sitePathPrefix = "") {
351
+ if (isFullUrl(url) || url.startsWith("//")) return url;
352
+ return toAbsoluteSiteUrl(url, siteUrl, sitePathPrefix);
353
+ }
333
354
  //#endregion
334
- export { url_exports as _, getSitePathPrefix as a, normalizePath as c, sanitizeUrl as d, slugify as f, toPublicPath as g, toPublicHref as h, getSiteOrigin as i, normalizeSitePathPrefix as l, toAbsoluteSiteUrl as m, extractDisplayDomain as n, isFullUrl as o, stripSitePathPrefix as p, extractDomain as r, isSafeInternalRedirect as s, buildSiteUrl as t, normalizeSiteUrl as u, __exportAll as v };
355
+ export { toPublicPath as _, getSitePathPrefix as a, normalizePath as c, sanitizeUrl as d, slugify as f, toPublicHref as g, toAbsoluteSiteUrl as h, getSiteOrigin as i, normalizeSitePathPrefix as l, toAbsoluteAssetUrl as m, extractDisplayDomain as n, isFullUrl as o, stripSitePathPrefix as p, extractDomain as r, isSafeInternalRedirect as s, buildSiteUrl as t, normalizeSiteUrl as u, url_exports as v, __exportAll as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jant/core",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "A modern, open-source microblogging platform built on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { planImageProcessing } from "../image-processor.js";
3
+
4
+ const OPTS = { maxShortSide: 1920, maxLongSide: 8192 };
5
+
6
+ describe("planImageProcessing", () => {
7
+ it("leaves small images untouched", () => {
8
+ expect(planImageProcessing(800, 600, OPTS)).toEqual({
9
+ passthrough: false,
10
+ width: 800,
11
+ height: 600,
12
+ });
13
+ });
14
+
15
+ it("keeps long screenshots at full resolution", () => {
16
+ expect(planImageProcessing(1080, 6000, OPTS)).toEqual({
17
+ passthrough: false,
18
+ width: 1080,
19
+ height: 6000,
20
+ });
21
+ });
22
+
23
+ it("keeps wide screenshots at full resolution", () => {
24
+ expect(planImageProcessing(6000, 1080, OPTS)).toEqual({
25
+ passthrough: false,
26
+ width: 6000,
27
+ height: 1080,
28
+ });
29
+ });
30
+
31
+ it("downscales a large photo by its short side, not its long side", () => {
32
+ const plan = planImageProcessing(4032, 3024, OPTS);
33
+ expect(plan.passthrough).toBe(false);
34
+ expect(plan.height).toBe(1920);
35
+ expect(plan.width).toBe(Math.round(4032 * (1920 / 3024)));
36
+ });
37
+
38
+ it("caps the short side regardless of orientation", () => {
39
+ const portrait = planImageProcessing(3024, 4032, OPTS);
40
+ expect(portrait.width).toBe(1920);
41
+ expect(portrait.height).toBe(Math.round(4032 * (1920 / 3024)));
42
+ });
43
+
44
+ it("uploads images taller than the canvas limit untouched", () => {
45
+ expect(planImageProcessing(1080, 12000, OPTS)).toEqual({
46
+ passthrough: true,
47
+ width: 1080,
48
+ height: 12000,
49
+ });
50
+ });
51
+
52
+ it("uploads images wider than the canvas limit untouched", () => {
53
+ expect(planImageProcessing(12000, 1080, OPTS)).toEqual({
54
+ passthrough: true,
55
+ width: 12000,
56
+ height: 1080,
57
+ });
58
+ });
59
+
60
+ it("treats the long-side cap as inclusive", () => {
61
+ expect(planImageProcessing(1080, 8192, OPTS).passthrough).toBe(false);
62
+ expect(planImageProcessing(1080, 8193, OPTS).passthrough).toBe(true);
63
+ });
64
+ });
@@ -94,7 +94,7 @@ describe("JantMediaLightbox", () => {
94
94
  ).toBe(false);
95
95
  });
96
96
 
97
- it("renders tall images in a scrollable stage", async () => {
97
+ it("previews tall images contained, then expands to a scrollable stage on click", async () => {
98
98
  const el = await createElement();
99
99
 
100
100
  el.open(
@@ -110,14 +110,67 @@ describe("JantMediaLightbox", () => {
110
110
  );
111
111
  await flush(el);
112
112
 
113
- const stage = el.querySelector(".media-lightbox-stage");
114
- const img = el.querySelector(".media-lightbox-img");
113
+ const initialStage = el.querySelector(".media-lightbox-stage");
114
+ const initialImg = el.querySelector<HTMLImageElement>(
115
+ ".media-lightbox-img",
116
+ );
115
117
 
116
- expect(stage?.classList.contains("media-lightbox-stage-scroll")).toBe(true);
117
- expect(img?.classList.contains("media-lightbox-img-scroll")).toBe(true);
118
+ expect(
119
+ initialStage?.classList.contains("media-lightbox-stage-scroll"),
120
+ ).toBe(false);
121
+ expect(initialImg?.classList.contains("media-lightbox-img-scroll")).toBe(
122
+ false,
123
+ );
124
+ expect(initialImg?.classList.contains("media-lightbox-img-zoomable")).toBe(
125
+ true,
126
+ );
127
+
128
+ initialImg?.dispatchEvent(
129
+ new MouseEvent("click", { bubbles: true, cancelable: true }),
130
+ );
131
+ await flush(el);
132
+
133
+ const zoomedStage = el.querySelector(".media-lightbox-stage");
134
+ const zoomedImg = el.querySelector(".media-lightbox-img");
135
+ expect(zoomedStage?.classList.contains("media-lightbox-stage-scroll")).toBe(
136
+ true,
137
+ );
138
+ expect(zoomedImg?.classList.contains("media-lightbox-img-scroll")).toBe(
139
+ true,
140
+ );
141
+
142
+ zoomedImg?.dispatchEvent(
143
+ new MouseEvent("click", { bubbles: true, cancelable: true }),
144
+ );
145
+ await flush(el);
146
+
147
+ const collapsedStage = el.querySelector(".media-lightbox-stage");
148
+ expect(
149
+ collapsedStage?.classList.contains("media-lightbox-stage-scroll"),
150
+ ).toBe(false);
118
151
  });
119
152
 
120
- it("resets stage scroll when navigating between images", async () => {
153
+ it("does not show the zoom affordance for regular-aspect images", async () => {
154
+ const el = await createElement();
155
+
156
+ el.open(
157
+ [
158
+ {
159
+ url: "https://example.com/wide.jpg",
160
+ alt: "",
161
+ width: 1600,
162
+ height: 900,
163
+ },
164
+ ],
165
+ 0,
166
+ );
167
+ await flush(el);
168
+
169
+ const img = el.querySelector(".media-lightbox-img");
170
+ expect(img?.classList.contains("media-lightbox-img-zoomable")).toBe(false);
171
+ });
172
+
173
+ it("resets zoom and stage scroll when navigating between images", async () => {
121
174
  const el = await createElement();
122
175
 
123
176
  el.open(
@@ -139,10 +192,18 @@ describe("JantMediaLightbox", () => {
139
192
  );
140
193
  await flush(el);
141
194
 
195
+ el.querySelector<HTMLImageElement>(".media-lightbox-img")?.dispatchEvent(
196
+ new MouseEvent("click", { bubbles: true, cancelable: true }),
197
+ );
198
+ await flush(el);
199
+
142
200
  const initialStage = el.querySelector<HTMLElement>(".media-lightbox-stage");
143
201
  if (!initialStage) {
144
202
  throw new Error("expected lightbox stage");
145
203
  }
204
+ expect(initialStage.classList.contains("media-lightbox-stage-scroll")).toBe(
205
+ true,
206
+ );
146
207
  initialStage.scrollTop = 180;
147
208
 
148
209
  el.querySelector<HTMLButtonElement>(".media-lightbox-nav-next")?.click();
@@ -150,6 +211,9 @@ describe("JantMediaLightbox", () => {
150
211
 
151
212
  const nextStage = el.querySelector<HTMLElement>(".media-lightbox-stage");
152
213
  expect(nextStage?.scrollTop).toBe(0);
214
+ expect(nextStage?.classList.contains("media-lightbox-stage-scroll")).toBe(
215
+ false,
216
+ );
153
217
  });
154
218
 
155
219
  it("uses natural image dimensions for inline post-body images", async () => {
@@ -179,8 +243,15 @@ describe("JantMediaLightbox", () => {
179
243
  );
180
244
  await flush(el);
181
245
 
182
- const stage = el.querySelector(".media-lightbox-stage");
183
- expect(stage?.classList.contains("media-lightbox-stage-scroll")).toBe(true);
246
+ const lightboxImg = el.querySelector<HTMLImageElement>(
247
+ ".media-lightbox-img",
248
+ );
249
+ expect(lightboxImg?.classList.contains("media-lightbox-img-zoomable")).toBe(
250
+ true,
251
+ );
252
+ expect(lightboxImg?.classList.contains("media-lightbox-img-scroll")).toBe(
253
+ false,
254
+ );
184
255
  });
185
256
 
186
257
  it("renders custom controls for short videos", async () => {
@@ -2524,9 +2524,9 @@ export class JantComposeEditor extends LitElement {
2524
2524
  : this.format === "link"
2525
2525
  ? this._renderLinkFields()
2526
2526
  : this._renderQuoteFields()}
2527
- ${this._renderStarRating()}
2528
2527
  </section>
2529
- ${this._renderAttachmentDock()} ${this._renderToolsRow()}
2528
+ ${this._renderAttachmentDock()} ${this._renderStarRating()}
2529
+ ${this._renderToolsRow()}
2530
2530
  `;
2531
2531
  }
2532
2532
  }
@@ -140,6 +140,7 @@ export class JantMediaLightbox extends LitElement {
140
140
  _videoCurrentTime: { state: true },
141
141
  _videoDuration: { state: true },
142
142
  _videoMuted: { state: true },
143
+ _imageZoomed: { state: true },
143
144
  };
144
145
 
145
146
  declare _images: LightboxImage[];
@@ -150,6 +151,7 @@ export class JantMediaLightbox extends LitElement {
150
151
  declare _videoCurrentTime: number;
151
152
  declare _videoDuration: number;
152
153
  declare _videoMuted: boolean;
154
+ declare _imageZoomed: boolean;
153
155
 
154
156
  createRenderRoot() {
155
157
  this.innerHTML = "";
@@ -167,6 +169,7 @@ export class JantMediaLightbox extends LitElement {
167
169
  this._videoCurrentTime = 0;
168
170
  this._videoDuration = 0;
169
171
  this._videoMuted = false;
172
+ this._imageZoomed = false;
170
173
  }
171
174
 
172
175
  connectedCallback() {
@@ -188,6 +191,7 @@ export class JantMediaLightbox extends LitElement {
188
191
  this._images = images;
189
192
  this._currentIndex = Math.max(0, Math.min(index, images.length - 1));
190
193
  this.#resetShortVideoState(this._images[this._currentIndex]);
194
+ this._imageZoomed = false;
191
195
  this._open = true;
192
196
  document.dispatchEvent(
193
197
  new CustomEvent(MEDIA_LIGHTBOX_TOGGLE_EVENT, {
@@ -267,6 +271,7 @@ export class JantMediaLightbox extends LitElement {
267
271
  #prev() {
268
272
  if (this._images.length <= 1) return;
269
273
  this.#pauseCurrentVideo();
274
+ this._imageZoomed = false;
270
275
  this._currentIndex =
271
276
  (this._currentIndex - 1 + this._images.length) % this._images.length;
272
277
  }
@@ -274,9 +279,22 @@ export class JantMediaLightbox extends LitElement {
274
279
  #next() {
275
280
  if (this._images.length <= 1) return;
276
281
  this.#pauseCurrentVideo();
282
+ this._imageZoomed = false;
277
283
  this._currentIndex = (this._currentIndex + 1) % this._images.length;
278
284
  }
279
285
 
286
+ #handleImageClick = (e: Event) => {
287
+ const img = this._images[this._currentIndex];
288
+ const eligible = shouldUseScrollableLightboxImage(
289
+ img,
290
+ this._viewportWidth,
291
+ this._viewportHeight,
292
+ );
293
+ if (!eligible) return;
294
+ e.stopPropagation();
295
+ this._imageZoomed = !this._imageZoomed;
296
+ };
297
+
280
298
  #handleKeydown = (e: Event) => {
281
299
  const ke = e as globalThis.KeyboardEvent;
282
300
  const target = e.target as HTMLElement | null;
@@ -531,14 +549,22 @@ export class JantMediaLightbox extends LitElement {
531
549
  super.updated(changed);
532
550
 
533
551
  if (!this._open) return;
534
- if (!changed.has("_currentIndex") && !changed.has("_open")) return;
552
+ if (
553
+ !changed.has("_currentIndex") &&
554
+ !changed.has("_open") &&
555
+ !changed.has("_imageZoomed")
556
+ ) {
557
+ return;
558
+ }
535
559
 
536
560
  const stage = this.querySelector<HTMLElement>(".media-lightbox-stage");
537
561
  if (!stage) return;
538
562
  stage.scrollTop = 0;
539
563
  stage.scrollLeft = 0;
540
- this.#syncCurrentVideo();
541
- this.#focusCurrentMedia();
564
+ if (changed.has("_currentIndex") || changed.has("_open")) {
565
+ this.#syncCurrentVideo();
566
+ this.#focusCurrentMedia();
567
+ }
542
568
  }
543
569
 
544
570
  render() {
@@ -548,11 +574,12 @@ export class JantMediaLightbox extends LitElement {
548
574
  const multiple = this._images.length > 1;
549
575
  const isVideo = img?.mimeType?.startsWith("video/");
550
576
  const usesShortVideoControls = shouldUseShortVideoExperience(img);
551
- const isScrollableImage = shouldUseScrollableLightboxImage(
577
+ const isScrollableEligible = shouldUseScrollableLightboxImage(
552
578
  img,
553
579
  this._viewportWidth,
554
580
  this._viewportHeight,
555
581
  );
582
+ const isScrollableImage = isScrollableEligible && this._imageZoomed;
556
583
  const shortVideoFrameSize = usesShortVideoControls
557
584
  ? getContainedLightboxMediaSize(
558
585
  img,
@@ -683,9 +710,10 @@ export class JantMediaLightbox extends LitElement {
683
710
  @focus=${this.#handleVideoFocus}
684
711
  ></video>`
685
712
  : html`<img
686
- class=${`media-lightbox-img${isScrollableImage ? " media-lightbox-img-scroll" : ""}`}
713
+ class=${`media-lightbox-img${isScrollableEligible ? " media-lightbox-img-zoomable" : ""}${isScrollableImage ? " media-lightbox-img-scroll" : ""}`}
687
714
  src=${img?.url ?? ""}
688
715
  alt=${img?.alt ?? ""}
716
+ @click=${this.#handleImageClick}
689
717
  />`}
690
718
  </div>
691
719
  ${multiple
@@ -2,18 +2,35 @@
2
2
  * Client-side Image Processor
3
3
  *
4
4
  * Processes images before upload:
5
- * - Resizes to max dimensions
5
+ * - Resizes oversized images (caps the short side; the long side rides free)
6
6
  * - Strips all metadata (privacy)
7
7
  * - Converts to WebP format (JPEG fallback when WebP encoding is unavailable)
8
8
  *
9
9
  * EXIF orientation is handled automatically by the browser — modern
10
10
  * engines (Chrome 81+, Safari 13.1+, Firefox 93+) apply orientation
11
11
  * both in `<img>` rendering and in canvas `drawImage`.
12
+ *
13
+ * Long and wide screenshots (chat logs, articles, wide tables) lose their
14
+ * text legibility if the short side is scaled down, so the resize step caps
15
+ * only the *short* side and leaves the long side alone. Images whose long
16
+ * side exceeds the safe canvas limit can't be redrawn at all — those upload
17
+ * untouched, so images of any length are supported.
18
+ */
19
+
20
+ /** Cap for the shorter image side — the side that determines text sharpness. */
21
+ const MAX_SHORT_SIDE = 1920;
22
+
23
+ /**
24
+ * Largest long side we can still redraw on a canvas. A canvas bounded by
25
+ * MAX_SHORT_SIDE × MAX_LONG_SIDE (1920 × 8192 ≈ 15.7M px) stays under the
26
+ * ~16.7M-pixel area limit older mobile Safari enforces. Anything longer
27
+ * can't be re-encoded, so it uploads as-is.
12
28
  */
29
+ const MAX_LONG_SIDE = 8192;
13
30
 
14
31
  const DEFAULT_OPTIONS = {
15
- maxWidth: 1920,
16
- maxHeight: 1920,
32
+ maxShortSide: MAX_SHORT_SIDE,
33
+ maxLongSide: MAX_LONG_SIDE,
17
34
  quality: 0.85,
18
35
  mimeType: "image/webp" as const,
19
36
  };
@@ -35,23 +52,52 @@ function loadImage(file: File): Promise<HTMLImageElement> {
35
52
  });
36
53
  }
37
54
 
55
+ export interface ImageProcessPlan {
56
+ /** When true, upload the original file untouched (too large to re-encode). */
57
+ passthrough: boolean;
58
+ /** Target dimensions — equal to the source dimensions when `passthrough`. */
59
+ width: number;
60
+ height: number;
61
+ }
62
+
38
63
  /**
39
- * Calculate output dimensions maintaining aspect ratio
64
+ * Decide how to handle an image given its source dimensions.
65
+ *
66
+ * - Long side over `maxLongSide` → `passthrough` (canvas can't redraw it).
67
+ * - Short side within `maxShortSide` → keep dimensions, just re-encode.
68
+ * - Otherwise → scale down so the short side hits `maxShortSide`.
69
+ *
70
+ * @param sourceWidth - Natural image width in pixels
71
+ * @param sourceHeight - Natural image height in pixels
72
+ * @param options - `maxShortSide` and `maxLongSide` caps
73
+ * @returns The processing plan
74
+ *
75
+ * @example
76
+ * ```ts
77
+ * planImageProcessing(1080, 6000, { maxShortSide: 1920, maxLongSide: 8192 });
78
+ * // { passthrough: false, width: 1080, height: 6000 }
79
+ * ```
40
80
  */
41
- function calculateDimensions(
42
- width: number,
43
- height: number,
44
- maxWidth: number,
45
- maxHeight: number,
46
- ): { width: number; height: number } {
47
- if (width <= maxWidth && height <= maxHeight) {
48
- return { width, height };
81
+ export function planImageProcessing(
82
+ sourceWidth: number,
83
+ sourceHeight: number,
84
+ options: { maxShortSide: number; maxLongSide: number },
85
+ ): ImageProcessPlan {
86
+ const longSide = Math.max(sourceWidth, sourceHeight);
87
+ if (longSide > options.maxLongSide) {
88
+ return { passthrough: true, width: sourceWidth, height: sourceHeight };
89
+ }
90
+
91
+ const shortSide = Math.min(sourceWidth, sourceHeight);
92
+ if (shortSide <= options.maxShortSide) {
93
+ return { passthrough: false, width: sourceWidth, height: sourceHeight };
49
94
  }
50
95
 
51
- const ratio = Math.min(maxWidth / width, maxHeight / height);
96
+ const scale = options.maxShortSide / shortSide;
52
97
  return {
53
- width: Math.round(width * ratio),
54
- height: Math.round(height * ratio),
98
+ passthrough: false,
99
+ width: Math.round(sourceWidth * scale),
100
+ height: Math.round(sourceHeight * scale),
55
101
  };
56
102
  }
57
103
 
@@ -92,6 +138,8 @@ export interface ProcessResult {
92
138
  blob: Blob;
93
139
  width: number;
94
140
  height: number;
141
+ /** False when `blob` is the untouched original (too large to re-encode). */
142
+ processed: boolean;
95
143
  }
96
144
 
97
145
  export interface ProcessToFileResult {
@@ -112,26 +160,32 @@ async function process(
112
160
  const img = await loadImage(file);
113
161
 
114
162
  // img.width / img.height already reflect EXIF orientation in modern browsers
115
- const { width, height } = calculateDimensions(
116
- img.width,
117
- img.height,
118
- opts.maxWidth,
119
- opts.maxHeight,
120
- );
163
+ const plan = planImageProcessing(img.width, img.height, opts);
164
+
165
+ // Too large to redraw on a canvas without crushing detail — keep the
166
+ // original bytes so images of any length upload at full quality.
167
+ if (plan.passthrough) {
168
+ return {
169
+ blob: file,
170
+ width: plan.width,
171
+ height: plan.height,
172
+ processed: false,
173
+ };
174
+ }
121
175
 
122
176
  const canvas = document.createElement("canvas");
123
- canvas.width = width;
124
- canvas.height = height;
177
+ canvas.width = plan.width;
178
+ canvas.height = plan.height;
125
179
 
126
180
  const ctx = canvas.getContext("2d");
127
181
  if (!ctx) throw new Error("Failed to get canvas context");
128
182
 
129
183
  // drawImage respects EXIF orientation — no manual rotation needed
130
- ctx.drawImage(img, 0, 0, width, height);
184
+ ctx.drawImage(img, 0, 0, plan.width, plan.height);
131
185
 
132
186
  const blob = await canvasToBlob(canvas, opts.mimeType, opts.quality);
133
187
 
134
- return { blob, width, height };
188
+ return { blob, width: plan.width, height: plan.height, processed: true };
135
189
  }
136
190
 
137
191
  /**
@@ -141,7 +195,12 @@ async function processToFile(
141
195
  file: File,
142
196
  options: ProcessOptions = {},
143
197
  ): Promise<ProcessToFileResult> {
144
- const { blob, width, height } = await process(file, options);
198
+ const result = await process(file, options);
199
+
200
+ // Original kept untouched — upload the file as-is.
201
+ if (!result.processed) {
202
+ return { file, width: result.width, height: result.height };
203
+ }
145
204
 
146
205
  // Use actual blob type — Safari falls back to JPEG when WebP encoding isn't supported
147
206
  const EXT_MAP: Record<string, string> = {
@@ -149,14 +208,14 @@ async function processToFile(
149
208
  "image/jpeg": "jpg",
150
209
  "image/png": "png",
151
210
  };
152
- const ext = EXT_MAP[blob.type] ?? "png";
211
+ const ext = EXT_MAP[result.blob.type] ?? "png";
153
212
  const originalName = file.name.replace(/\.[^.]+$/, "");
154
213
  const newName = `${originalName}.${ext}`;
155
214
 
156
215
  return {
157
- file: new File([blob], newName, { type: blob.type }),
158
- width,
159
- height,
216
+ file: new File([result.blob], newName, { type: result.blob.type }),
217
+ width: result.width,
218
+ height: result.height,
160
219
  };
161
220
  }
162
221
 
@@ -1,16 +1,24 @@
1
1
  /**
2
- * Media gallery horizontal scroll fade hints.
2
+ * Media gallery horizontal scroll affordances.
3
3
  *
4
- * Toggles `.can-scroll-start` / `.can-scroll-end` on `.media-gallery-scroll-wrap`
5
- * based on the inner scroller's scroll position.
4
+ * The gallery strip (`[data-post-media]` inside `.media-gallery-scroll-wrap`)
5
+ * is trackpad- and touch-friendly, but its scrollbar is hidden, so a plain
6
+ * mouse or the keyboard has no obvious way to scroll it. This module:
7
+ *
8
+ * - Toggles `.can-scroll-start` / `.can-scroll-end` on the wrap so CSS can
9
+ * fade the edge that has hidden content and reveal the matching arrow.
10
+ * - Wires the prev/next arrow buttons to scroll the strip by one page.
11
+ * - Scrolls the strip with Arrow / Home / End keys while it is focused.
6
12
  */
7
13
 
8
14
  const THRESHOLD = 4; // px tolerance for "at edge"
9
15
 
16
+ function getScroller(wrap: HTMLElement): HTMLElement | null {
17
+ return wrap.querySelector("[data-post-media]");
18
+ }
19
+
10
20
  function updateHints(wrap: HTMLElement): void {
11
- const scroller = wrap.querySelector(
12
- "[data-post-media]",
13
- ) as HTMLElement | null;
21
+ const scroller = getScroller(wrap);
14
22
  if (!scroller) return;
15
23
 
16
24
  const { scrollLeft, scrollWidth, clientWidth } = scroller;
@@ -21,16 +29,61 @@ function updateHints(wrap: HTMLElement): void {
21
29
  );
22
30
  }
23
31
 
32
+ /** Horizontal distance moved per arrow-button click or arrow keypress. */
33
+ function pageStep(scroller: HTMLElement): number {
34
+ return Math.max(160, Math.round(scroller.clientWidth * 0.85));
35
+ }
36
+
37
+ function scrollByStep(scroller: HTMLElement, direction: 1 | -1): void {
38
+ scroller.scrollBy({
39
+ left: direction * pageStep(scroller),
40
+ behavior: "smooth",
41
+ });
42
+ }
43
+
24
44
  function initWrap(wrap: HTMLElement): void {
25
- const scroller = wrap.querySelector("[data-post-media]");
45
+ // Guard against double-init (initAll + the MutationObserver can overlap).
46
+ if (wrap.dataset.scrollHintReady === "1") return;
47
+ const scroller = getScroller(wrap);
26
48
  if (!scroller) return;
49
+ wrap.dataset.scrollHintReady = "1";
27
50
 
28
- // Initial check
51
+ // Initial check + keep edge hints in sync while scrolling.
29
52
  updateHints(wrap);
30
-
31
53
  scroller.addEventListener("scroll", () => updateHints(wrap), {
32
54
  passive: true,
33
55
  });
56
+
57
+ // Prev/next arrow buttons — the mouse affordance.
58
+ wrap
59
+ .querySelector(".media-gallery-nav-prev")
60
+ ?.addEventListener("click", () => scrollByStep(scroller, -1));
61
+ wrap
62
+ .querySelector(".media-gallery-nav-next")
63
+ ?.addEventListener("click", () => scrollByStep(scroller, 1));
64
+
65
+ // Keyboard scrolling while the strip itself is focused.
66
+ scroller.addEventListener("keydown", (event) => {
67
+ if (event.target !== scroller) return;
68
+ switch (event.key) {
69
+ case "ArrowRight":
70
+ event.preventDefault();
71
+ scrollByStep(scroller, 1);
72
+ break;
73
+ case "ArrowLeft":
74
+ event.preventDefault();
75
+ scrollByStep(scroller, -1);
76
+ break;
77
+ case "Home":
78
+ event.preventDefault();
79
+ scroller.scrollTo({ left: 0, behavior: "smooth" });
80
+ break;
81
+ case "End":
82
+ event.preventDefault();
83
+ scroller.scrollTo({ left: scroller.scrollWidth, behavior: "smooth" });
84
+ break;
85
+ }
86
+ });
34
87
  }
35
88
 
36
89
  // Init all existing galleries