@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.
- package/dist/app-CUZaVgsC.js +6 -0
- package/dist/{app-DYQdDMs8.js → app-Ct9c4zYF.js} +296 -50
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-Bp2IPjDe.js +275 -0
- package/dist/client/_assets/client-YVrRjAid.css +2 -0
- package/dist/client/_assets/{client-auth-CSNcTJwP.js → client-auth-C4hQWqH1.js} +4 -4
- package/dist/{env-C7e2Nlnt.js → env-CoSe-1y4.js} +1 -1
- package/dist/{export-Bbn86HmS.js → export-O2w3AsZX.js} +4 -4
- package/dist/{github-api-Bh0PH3zr.js → github-api-UD4u_7fa.js} +1 -1
- package/dist/{github-app-D0GvNnqp.js → github-app-DeX6Td1O.js} +1 -1
- package/dist/{github-sync-dXsiZa_e.js → github-sync-BUzIYouS.js} +3 -3
- package/dist/{github-sync-CBQPRZ8H.js → github-sync-D49RADci.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-umUptr5z.js → url-XF0GbKGO.js} +22 -1
- package/package.json +1 -1
- package/src/client/__tests__/image-processor.test.ts +64 -0
- package/src/client/components/__tests__/jant-media-lightbox.test.ts +79 -8
- package/src/client/components/jant-compose-editor.ts +2 -2
- package/src/client/components/jant-media-lightbox.ts +33 -5
- package/src/client/image-processor.ts +89 -30
- package/src/client/media-scroll-hint.ts +62 -9
- package/src/i18n/coverage.generated.ts +2 -2
- package/src/i18n/locales/settings/zh-Hans.po +24 -24
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +24 -24
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/structured-data.test.ts +87 -0
- package/src/lib/post-display.ts +78 -1
- package/src/lib/render.tsx +28 -0
- package/src/lib/structured-data.ts +113 -0
- package/src/lib/url.ts +26 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +65 -0
- package/src/routes/api/internal/sites.ts +19 -0
- package/src/routes/pages/home.tsx +21 -1
- package/src/routes/pages/page.tsx +53 -2
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +30 -29
- package/src/services/export-theme/layouts/partials/media-gallery.html +16 -7
- package/src/services/site-admin.ts +53 -1
- package/src/styles/site-media.css +70 -24
- package/src/styles/ui.css +7 -2
- package/src/ui/layouts/BaseLayout.tsx +110 -16
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +146 -0
- package/src/ui/shared/MediaGallery.tsx +50 -7
- package/src/ui/shared/__tests__/media-gallery.test.ts +31 -0
- package/dist/app-CMSW_AYG.js +0 -6
- package/dist/client/_assets/client-BRTh1ii1.js +0 -274
- package/dist/client/_assets/client-CO4b-RKd.css +0 -2
package/dist/node.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import "./url-
|
|
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-
|
|
3
|
-
import { t as createExportService } from "./export-
|
|
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-
|
|
5
|
-
import "./github-sync-
|
|
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-
|
|
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 {
|
|
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
|
@@ -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("
|
|
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
|
|
114
|
-
const
|
|
113
|
+
const initialStage = el.querySelector(".media-lightbox-stage");
|
|
114
|
+
const initialImg = el.querySelector<HTMLImageElement>(
|
|
115
|
+
".media-lightbox-img",
|
|
116
|
+
);
|
|
115
117
|
|
|
116
|
-
expect(
|
|
117
|
-
|
|
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("
|
|
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
|
|
183
|
-
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
541
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
*
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (
|
|
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
|
|
96
|
+
const scale = options.maxShortSide / shortSide;
|
|
52
97
|
return {
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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
|
|
2
|
+
* Media gallery horizontal scroll affordances.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|