@jant/core 0.5.1 → 0.5.3

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 (43) hide show
  1. package/dist/app-BgMwEN-M.js +6 -0
  2. package/dist/{app-D_ke5q_u.js → app-C481ssbr.js} +45 -25
  3. package/dist/client/.vite/manifest.json +3 -3
  4. package/dist/client/_assets/{client-DkbGkmgp.js → client-CJQYvkEx.js} +10 -8
  5. package/dist/client/_assets/client-CQvi1Buw.css +2 -0
  6. package/dist/client/_assets/{client-auth-BRrao4p4.js → client-auth-CfBiCAB7.js} +95 -67
  7. package/dist/{export-I9XFTWyO.js → export-CR9Megtb.js} +2 -2
  8. package/dist/{github-sync-C0Fi4LKt.js → github-sync-8Vv06aCr.js} +2 -2
  9. package/dist/{github-sync-DBAwA3H9.js → github-sync-DYZq9rQp.js} +1 -1
  10. package/dist/index.js +3 -3
  11. package/dist/node.js +4 -4
  12. package/package.json +1 -1
  13. package/src/client/__tests__/slash-discovery.test.ts +150 -0
  14. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1 -0
  15. package/src/client/components/__tests__/jant-compose-editor.test.ts +1 -0
  16. package/src/client/components/compose-types.ts +1 -0
  17. package/src/client/components/jant-compose-dialog.ts +35 -6
  18. package/src/client/components/jant-compose-editor.ts +51 -7
  19. package/src/client/components/jant-media-lightbox.ts +124 -39
  20. package/src/client/slash-discovery-bridge.ts +9 -0
  21. package/src/client/slash-discovery.ts +200 -0
  22. package/src/client/tiptap/slash-commands.ts +5 -0
  23. package/src/client-auth.ts +1 -0
  24. package/src/i18n/locales/public/en.po +5 -0
  25. package/src/i18n/locales/public/en.ts +1 -1
  26. package/src/i18n/locales/public/zh-Hans.po +5 -0
  27. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  28. package/src/i18n/locales/public/zh-Hant.po +5 -0
  29. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  30. package/src/lib/render.tsx +3 -0
  31. package/src/routes/api/__tests__/settings.test.ts +59 -0
  32. package/src/routes/api/settings.ts +19 -0
  33. package/src/routes/pages/new.tsx +6 -0
  34. package/src/services/export-theme/assets/client-site.js +9 -7
  35. package/src/styles/tokens.css +40 -0
  36. package/src/styles/ui.css +83 -7
  37. package/src/types/config.ts +5 -0
  38. package/src/types/views.ts +1 -0
  39. package/src/ui/compose/ComposeDialog.tsx +12 -0
  40. package/src/ui/layouts/SiteLayout.tsx +2 -0
  41. package/src/ui/pages/ComposePage.tsx +3 -0
  42. package/dist/app-BpyntjN7.js +0 -6
  43. package/dist/client/_assets/client-Hxj-LpVt.css +0 -2
@@ -0,0 +1,150 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import {
5
+ __testOnly,
6
+ hideSlashCommandHint,
7
+ markSlashCommandDiscovered,
8
+ scheduleSlashCommandHint,
9
+ } from "../slash-discovery.js";
10
+
11
+ function createEditorHost(discovered = false): HTMLElement {
12
+ document.body.innerHTML = `
13
+ <jant-compose-editor data-slash-command-discovered="${discovered ? "true" : "false"}">
14
+ <div class="compose-tiptap-body"></div>
15
+ <span class="compose-slash-discovery-hint" aria-hidden="true">Type / for commands</span>
16
+ </jant-compose-editor>
17
+ `;
18
+
19
+ return document.querySelector<HTMLElement>(
20
+ "jant-compose-editor",
21
+ ) as HTMLElement;
22
+ }
23
+
24
+ describe("slash discovery", () => {
25
+ beforeEach(() => {
26
+ vi.restoreAllMocks();
27
+ vi.useFakeTimers();
28
+ globalThis.localStorage.clear();
29
+ __testOnly.reset();
30
+ vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: true }));
31
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
32
+ });
33
+
34
+ afterEach(() => {
35
+ vi.useRealTimers();
36
+ vi.unstubAllGlobals();
37
+ });
38
+
39
+ it("shows the hint after the delay and records one exposure per page", () => {
40
+ const host = createEditorHost();
41
+
42
+ scheduleSlashCommandHint(host);
43
+ vi.advanceTimersByTime(__testOnly.SLASH_HINT_DELAY_MS);
44
+
45
+ expect(host.classList.contains(__testOnly.SLASH_HINT_VISIBLE_CLASS)).toBe(
46
+ true,
47
+ );
48
+ expect(__testOnly.readState()).toMatchObject({
49
+ shownCount: 1,
50
+ completed: false,
51
+ });
52
+
53
+ // No auto-fade — the hint stays visible until something explicitly hides it.
54
+ vi.advanceTimersByTime(5000);
55
+ expect(host.classList.contains(__testOnly.SLASH_HINT_VISIBLE_CLASS)).toBe(
56
+ true,
57
+ );
58
+
59
+ hideSlashCommandHint(host);
60
+ expect(host.classList.contains(__testOnly.SLASH_HINT_VISIBLE_CLASS)).toBe(
61
+ false,
62
+ );
63
+
64
+ // Refocusing during the same page load shows the hint again
65
+ // without bumping the persisted shownCount.
66
+ scheduleSlashCommandHint(host);
67
+ vi.advanceTimersByTime(__testOnly.SLASH_HINT_DELAY_MS);
68
+
69
+ expect(host.classList.contains(__testOnly.SLASH_HINT_VISIBLE_CLASS)).toBe(
70
+ true,
71
+ );
72
+ expect(__testOnly.readState().shownCount).toBe(1);
73
+ });
74
+
75
+ it("hides the hint immediately when called", () => {
76
+ const host = createEditorHost();
77
+
78
+ scheduleSlashCommandHint(host);
79
+ vi.advanceTimersByTime(__testOnly.SLASH_HINT_DELAY_MS);
80
+ expect(host.classList.contains(__testOnly.SLASH_HINT_VISIBLE_CLASS)).toBe(
81
+ true,
82
+ );
83
+
84
+ hideSlashCommandHint(host);
85
+ expect(host.classList.contains(__testOnly.SLASH_HINT_VISIBLE_CLASS)).toBe(
86
+ false,
87
+ );
88
+ });
89
+
90
+ it("does not show the hint after the local max has been reached", () => {
91
+ const host = createEditorHost();
92
+ globalThis.localStorage.setItem(
93
+ __testOnly.SLASH_DISCOVERY_STORAGE_KEY,
94
+ JSON.stringify({
95
+ shownCount: __testOnly.SLASH_HINT_MAX_SHOW_COUNT,
96
+ completed: false,
97
+ }),
98
+ );
99
+
100
+ scheduleSlashCommandHint(host);
101
+ vi.advanceTimersByTime(__testOnly.SLASH_HINT_DELAY_MS);
102
+
103
+ expect(host.classList.contains(__testOnly.SLASH_HINT_VISIBLE_CLASS)).toBe(
104
+ false,
105
+ );
106
+ });
107
+
108
+ it("does not show the hint when the DB flag is already set", () => {
109
+ const host = createEditorHost(true);
110
+
111
+ scheduleSlashCommandHint(host);
112
+ vi.advanceTimersByTime(__testOnly.SLASH_HINT_DELAY_MS);
113
+
114
+ expect(host.classList.contains(__testOnly.SLASH_HINT_VISIBLE_CLASS)).toBe(
115
+ false,
116
+ );
117
+ });
118
+
119
+ it("does not show the hint on mobile widths", () => {
120
+ vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: false }));
121
+ const host = createEditorHost();
122
+
123
+ scheduleSlashCommandHint(host);
124
+ vi.advanceTimersByTime(__testOnly.SLASH_HINT_DELAY_MS);
125
+
126
+ expect(host.classList.contains(__testOnly.SLASH_HINT_VISIBLE_CLASS)).toBe(
127
+ false,
128
+ );
129
+ });
130
+
131
+ it("marks the slash command as completed locally and syncs to the server", async () => {
132
+ const host = createEditorHost();
133
+
134
+ markSlashCommandDiscovered();
135
+ await Promise.resolve();
136
+
137
+ expect(__testOnly.readState()).toMatchObject({
138
+ shownCount: __testOnly.SLASH_HINT_MAX_SHOW_COUNT,
139
+ completed: true,
140
+ });
141
+ expect(host.dataset.slashCommandDiscovered).toBe("true");
142
+ expect(globalThis.fetch).toHaveBeenCalledWith(
143
+ __testOnly.SLASH_DISCOVERY_API_PATH,
144
+ expect.objectContaining({
145
+ method: "POST",
146
+ credentials: "same-origin",
147
+ }),
148
+ );
149
+ });
150
+ });
@@ -230,6 +230,7 @@ const labels: ComposeLabels = {
230
230
  showMore: "Show more",
231
231
  showLess: "Show less",
232
232
  newThread: "New Thread",
233
+ slashHint: "Type / for commands",
233
234
  collectionFormLabels: {
234
235
  titleLabel: "Title",
235
236
  titlePlaceholder: "My Collection",
@@ -264,6 +264,7 @@ const labels: ComposeLabels = {
264
264
  showMore: "Show more",
265
265
  showLess: "Show less",
266
266
  newThread: "New Thread",
267
+ slashHint: "Type / for commands",
267
268
  collectionFormLabels: {
268
269
  titleLabel: "Title",
269
270
  titlePlaceholder: "My Collection",
@@ -214,6 +214,7 @@ export interface ComposeLabels {
214
214
  showMore: string;
215
215
  showLess: string;
216
216
  newThread: string;
217
+ slashHint: string;
217
218
  collectionFormLabels: CollectionFormLabels;
218
219
  }
219
220
 
@@ -534,6 +534,10 @@ export class JantComposeDialog extends LitElement {
534
534
  pageMode: { type: Boolean, attribute: "page-mode" },
535
535
  closeHref: { type: String, attribute: "close-href" },
536
536
  autoRestoreDraft: { type: Boolean, attribute: "auto-restore-draft" },
537
+ slashCommandDiscovered: {
538
+ type: Boolean,
539
+ attribute: "slash-command-discovered",
540
+ },
537
541
  _format: { state: true },
538
542
  _status: { state: true },
539
543
  _loading: { state: true },
@@ -578,6 +582,7 @@ export class JantComposeDialog extends LitElement {
578
582
  declare pageMode: boolean;
579
583
  declare closeHref: string;
580
584
  declare autoRestoreDraft: boolean;
585
+ declare slashCommandDiscovered: boolean;
581
586
  declare _format: ComposeFormat;
582
587
  declare _status: "published" | "draft";
583
588
  declare _loading: boolean;
@@ -665,6 +670,7 @@ export class JantComposeDialog extends LitElement {
665
670
  this.pageMode = false;
666
671
  this.closeHref = "/";
667
672
  this.autoRestoreDraft = false;
673
+ this.slashCommandDiscovered = false;
668
674
  this._format = "note";
669
675
  this._status = "published";
670
676
  this._loading = false;
@@ -4121,7 +4127,9 @@ export class JantComposeDialog extends LitElement {
4121
4127
 
4122
4128
  private _getSubmitLabel(): string {
4123
4129
  if (this._editPostId) return this.labels.update;
4124
- if (this._replyToId) return this.labels.reply;
4130
+ if (this._replyToId) {
4131
+ return this._quietReply ? this.labels.quietReplyLabel : this.labels.reply;
4132
+ }
4125
4133
  if (this._visibility === "latest_hidden") {
4126
4134
  return this.labels.postHiddenFromLatest;
4127
4135
  }
@@ -5009,16 +5017,35 @@ export class JantComposeDialog extends LitElement {
5009
5017
  `;
5010
5018
  }
5011
5019
 
5012
- private _renderHideFromLatestQuickToggleRow() {
5013
- if (this._visibilityLocked) return nothing;
5014
- if (this._visibility === "private") return nothing;
5020
+ private _renderQuickActionsRow() {
5021
+ const hideFromLatest = this._renderHideFromLatestQuickToggle();
5022
+ const quietReply = this._renderQuietReplyQuickToggle();
5023
+ if (hideFromLatest === nothing && quietReply === nothing) return nothing;
5015
5024
  return html`
5016
5025
  <div class="compose-quick-actions-row">
5017
- ${this._renderHideFromLatestQuickToggle()}
5026
+ ${hideFromLatest} ${quietReply}
5018
5027
  </div>
5019
5028
  `;
5020
5029
  }
5021
5030
 
5031
+ private _renderQuietReplyQuickToggle() {
5032
+ if (!this._replyToId) return nothing;
5033
+ return html`
5034
+ <label class="compose-publish-quick-toggle">
5035
+ <input
5036
+ type="checkbox"
5037
+ class="input compose-publish-quick-toggle-input"
5038
+ .checked=${this._quietReply}
5039
+ ?disabled=${this._loading}
5040
+ @change=${(e: Event) => {
5041
+ this._quietReply = (e.target as HTMLInputElement).checked;
5042
+ }}
5043
+ />
5044
+ <span>${this.labels.quietReplyLabel}</span>
5045
+ </label>
5046
+ `;
5047
+ }
5048
+
5022
5049
  private _renderHideFromLatestQuickToggle() {
5023
5050
  if (this._visibilityLocked) return nothing;
5024
5051
  if (this._visibility === "private") return nothing;
@@ -5283,6 +5310,7 @@ export class JantComposeDialog extends LitElement {
5283
5310
  .uploadMaxFileSize=${this.uploadMaxFileSize}
5284
5311
  .threadItem=${true}
5285
5312
  .removable=${showRemove}
5313
+ .slashCommandDiscovered=${this.slashCommandDiscovered}
5286
5314
  data-thread-id=${item.id}
5287
5315
  ></jant-compose-editor>
5288
5316
  </div>
@@ -5387,6 +5415,7 @@ export class JantComposeDialog extends LitElement {
5387
5415
  .format=${this._format}
5388
5416
  .labels=${this.labels}
5389
5417
  .uploadMaxFileSize=${this.uploadMaxFileSize}
5418
+ .slashCommandDiscovered=${this.slashCommandDiscovered}
5390
5419
  ></jant-compose-editor>`;
5391
5420
 
5392
5421
  return html`
@@ -5431,7 +5460,7 @@ export class JantComposeDialog extends LitElement {
5431
5460
  ${this._renderCollectionSelector()}
5432
5461
  ${this._renderPublishButton()}
5433
5462
  </div>
5434
- ${this._renderHideFromLatestQuickToggleRow()}`}
5463
+ ${this._renderQuickActionsRow()}`}
5435
5464
  ${this._renderMobilePublishPanel()} ${this._renderAttachedPanel()}
5436
5465
  ${this._renderAltPanel()} ${this._renderDraftsPanel()}
5437
5466
  ${this._renderConfirmPanel()}
@@ -48,6 +48,11 @@ import {
48
48
  } from "../tiptap/inline-image-upload.js";
49
49
  import { isSafeAbsoluteUrl } from "../../lib/url.js";
50
50
  import { randomUUID } from "../random-uuid.js";
51
+ import {
52
+ hideSlashCommandHint,
53
+ markSlashCommandDiscovered,
54
+ scheduleSlashCommandHint,
55
+ } from "../slash-discovery.js";
51
56
 
52
57
  interface ComposeFilePickerCloseDetail {
53
58
  cancelled: boolean;
@@ -172,6 +177,7 @@ export class JantComposeEditor extends LitElement {
172
177
  uploadMaxFileSize: { type: Number },
173
178
  threadItem: { type: Boolean, attribute: "thread-item" },
174
179
  removable: { type: Boolean },
180
+ slashCommandDiscovered: { type: Boolean },
175
181
  _title: { state: true },
176
182
  _bodyJson: { state: true },
177
183
  _url: { state: true },
@@ -196,6 +202,7 @@ export class JantComposeEditor extends LitElement {
196
202
  declare uploadMaxFileSize: number;
197
203
  declare threadItem: boolean;
198
204
  declare removable: boolean;
205
+ declare slashCommandDiscovered: boolean;
199
206
  declare _title: string;
200
207
  declare _bodyJson: JSONContent | null;
201
208
  declare _url: string;
@@ -242,6 +249,7 @@ export class JantComposeEditor extends LitElement {
242
249
  this.uploadMaxFileSize = 500;
243
250
  this.threadItem = false;
244
251
  this.removable = false;
252
+ this.slashCommandDiscovered = false;
245
253
  this._title = "";
246
254
  this._bodyJson = null;
247
255
  this._url = "";
@@ -264,6 +272,10 @@ export class JantComposeEditor extends LitElement {
264
272
  connectedCallback() {
265
273
  super.connectedCallback();
266
274
  document.addEventListener("jant:slash-image", this._onSlashImage);
275
+ document.addEventListener(
276
+ "jant:slash-command-discovered",
277
+ this._onSlashCommandDiscovered,
278
+ );
267
279
  }
268
280
 
269
281
  disconnectedCallback() {
@@ -274,13 +286,22 @@ export class JantComposeEditor extends LitElement {
274
286
  this.#sortable?.destroy();
275
287
  this.#sortable = null;
276
288
  document.removeEventListener("jant:slash-image", this._onSlashImage);
289
+ document.removeEventListener(
290
+ "jant:slash-command-discovered",
291
+ this._onSlashCommandDiscovered,
292
+ );
277
293
  document.removeEventListener("click", this._onDocClickBound);
294
+ hideSlashCommandHint(this);
278
295
  this._emojiContainer?.remove();
279
296
  this._emojiPickerEl = null;
280
297
  this._filePickerCleanup?.();
281
298
  this._filePickerCleanup = null;
282
299
  }
283
300
 
301
+ private _onSlashCommandDiscovered = () => {
302
+ markSlashCommandDiscovered();
303
+ };
304
+
284
305
  private _onSlashImage = () => {
285
306
  // Skip when fullscreen is open — it has its own handler
286
307
  if (document.querySelector(".compose-fullscreen-dialog[open]")) return;
@@ -686,6 +707,10 @@ export class JantComposeEditor extends LitElement {
686
707
  const container = this.querySelector<HTMLElement>(".compose-tiptap-body");
687
708
  if (!container || this._editor) return;
688
709
 
710
+ this.dataset.slashCommandDiscovered = this.slashCommandDiscovered
711
+ ? "true"
712
+ : "false";
713
+
689
714
  this._editor = createTiptapEditor({
690
715
  element: container,
691
716
  placeholder:
@@ -697,9 +722,13 @@ export class JantComposeEditor extends LitElement {
697
722
  onUpdate: (json) => {
698
723
  this._bodyJson = json;
699
724
  this._ensureScrollBuffer();
725
+ hideSlashCommandHint(this);
700
726
  },
701
727
  onFocus: () => {
702
728
  this._lastFocusedField = null;
729
+ if (this._editor?.isEmpty) {
730
+ scheduleSlashCommandHint(this);
731
+ }
703
732
  },
704
733
  onSelectionUpdate: (selection) => {
705
734
  this._lastEditorSelection = selection;
@@ -1655,7 +1684,12 @@ export class JantComposeEditor extends LitElement {
1655
1684
  </div>
1656
1685
  `
1657
1686
  : nothing}
1658
- <div class="compose-tiptap-body"></div>
1687
+ <div class="compose-tiptap-wrap">
1688
+ <div class="compose-tiptap-body"></div>
1689
+ <span class="compose-slash-discovery-hint" aria-hidden="true">
1690
+ ${this.labels.slashHint}
1691
+ </span>
1692
+ </div>
1659
1693
  </div>
1660
1694
  `;
1661
1695
  }
@@ -1723,9 +1757,14 @@ export class JantComposeEditor extends LitElement {
1723
1757
  </p>`
1724
1758
  : nothing}
1725
1759
  <div class="compose-divider"></div>
1726
- <div
1727
- class="compose-tiptap-body compose-tiptap-thoughts compose-tiptap-link"
1728
- ></div>
1760
+ <div class="compose-tiptap-wrap">
1761
+ <div
1762
+ class="compose-tiptap-body compose-tiptap-thoughts compose-tiptap-link"
1763
+ ></div>
1764
+ <span class="compose-slash-discovery-hint" aria-hidden="true">
1765
+ ${this.labels.slashHint}
1766
+ </span>
1767
+ </div>
1729
1768
  </div>
1730
1769
  `;
1731
1770
  }
@@ -1789,9 +1828,14 @@ export class JantComposeEditor extends LitElement {
1789
1828
  class="compose-divider compose-divider-quote"
1790
1829
  aria-hidden="true"
1791
1830
  ></div>
1792
- <div
1793
- class="compose-tiptap-body compose-tiptap-thoughts compose-tiptap-thoughts-quote"
1794
- ></div>
1831
+ <div class="compose-tiptap-wrap">
1832
+ <div
1833
+ class="compose-tiptap-body compose-tiptap-thoughts compose-tiptap-thoughts-quote"
1834
+ ></div>
1835
+ <span class="compose-slash-discovery-hint" aria-hidden="true">
1836
+ ${this.labels.slashHint}
1837
+ </span>
1838
+ </div>
1795
1839
  </div>
1796
1840
  `;
1797
1841
  }
@@ -280,46 +280,130 @@ export class JantMediaLightbox extends LitElement {
280
280
  #handleKeydown = (e: Event) => {
281
281
  const ke = e as globalThis.KeyboardEvent;
282
282
  const target = e.target as HTMLElement | null;
283
+
283
284
  if (ke.key === "Escape") {
284
285
  e.preventDefault();
285
286
  this.close();
286
287
  return;
287
288
  }
288
- if (ke.key !== "ArrowLeft" && ke.key !== "ArrowRight") return;
289
289
 
290
- // Let the progress slider's native arrow-key seeking through.
291
- if (target?.classList.contains("media-lightbox-short-progress")) return;
290
+ // Don't hijack keys aimed at a focused control — the short-video progress
291
+ // slider, the mute/close/nav buttons, or the <video> itself (when focused,
292
+ // its native shortcuts already handle these keys). Let their native
293
+ // behavior run instead of double-handling.
294
+ if (
295
+ target instanceof HTMLInputElement ||
296
+ target instanceof HTMLButtonElement ||
297
+ target instanceof HTMLVideoElement
298
+ ) {
299
+ return;
300
+ }
292
301
 
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
302
  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) {
303
+ if (!currentImage?.mimeType?.startsWith("video/")) {
304
+ // Image galleries: arrow keys switch items.
305
+ if (ke.key === "ArrowLeft") {
306
+ e.preventDefault();
307
+ this.#prev();
308
+ } else if (ke.key === "ArrowRight") {
301
309
  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;
310
+ this.#next();
314
311
  }
315
312
  return;
316
313
  }
317
314
 
318
- e.preventDefault();
319
- if (ke.key === "ArrowLeft") this.#prev();
320
- else this.#next();
315
+ const video = this.querySelector<HTMLVideoElement>(".media-lightbox-video");
316
+ if (video) this.#handleVideoKeydown(ke, video);
321
317
  };
322
318
 
319
+ // Video shortcuts — play/pause, seek, volume, mute, fullscreen — handled at
320
+ // the dialog level so they work regardless of what's focused. Item switching
321
+ // happens via the on-screen prev/next buttons, matching YouTube/native
322
+ // player conventions.
323
+ #handleVideoKeydown(ke: globalThis.KeyboardEvent, video: HTMLVideoElement) {
324
+ const duration =
325
+ Number.isFinite(video.duration) && video.duration > 0
326
+ ? video.duration
327
+ : null;
328
+ const seekTo = (time: number) => {
329
+ const next =
330
+ duration != null
331
+ ? Math.max(0, Math.min(time, duration))
332
+ : Math.max(0, time);
333
+ video.currentTime = next;
334
+ this._videoCurrentTime = next;
335
+ };
336
+ const key = ke.key;
337
+ const lower = key.toLowerCase();
338
+
339
+ if (key === " " || lower === "k") {
340
+ ke.preventDefault();
341
+ if (video.paused) void video.play().catch(() => {});
342
+ else video.pause();
343
+ } else if (key === "ArrowLeft") {
344
+ ke.preventDefault();
345
+ seekTo(video.currentTime - 5);
346
+ } else if (key === "ArrowRight") {
347
+ ke.preventDefault();
348
+ seekTo(video.currentTime + 5);
349
+ } else if (key === "Home") {
350
+ ke.preventDefault();
351
+ seekTo(0);
352
+ } else if (key === "End") {
353
+ if (duration != null) {
354
+ ke.preventDefault();
355
+ seekTo(duration);
356
+ }
357
+ } else if (key.length === 1 && key >= "0" && key <= "9") {
358
+ if (duration != null) {
359
+ ke.preventDefault();
360
+ seekTo((Number(key) / 10) * duration);
361
+ }
362
+ } else if (key === "ArrowUp") {
363
+ ke.preventDefault();
364
+ video.volume = Math.min(1, video.volume + 0.05);
365
+ } else if (key === "ArrowDown") {
366
+ ke.preventDefault();
367
+ video.volume = Math.max(0, video.volume - 0.05);
368
+ } else if (lower === "m") {
369
+ ke.preventDefault();
370
+ const muted = !video.muted;
371
+ video.muted = muted;
372
+ this._videoMuted = muted;
373
+ } else if (lower === "f") {
374
+ ke.preventDefault();
375
+ this.#toggleVideoFullscreen(video);
376
+ }
377
+ }
378
+
379
+ #toggleVideoFullscreen(video: HTMLVideoElement) {
380
+ const doc = document as globalThis.Document & {
381
+ webkitFullscreenElement?: globalThis.Element | null;
382
+ webkitExitFullscreen?: () => void;
383
+ };
384
+ const el = video as HTMLVideoElement & {
385
+ webkitRequestFullscreen?: () => void;
386
+ webkitEnterFullscreen?: () => void;
387
+ };
388
+
389
+ if (document.fullscreenElement ?? doc.webkitFullscreenElement) {
390
+ if (document.exitFullscreen) {
391
+ void document.exitFullscreen().catch(() => {});
392
+ } else {
393
+ doc.webkitExitFullscreen?.();
394
+ }
395
+ return;
396
+ }
397
+
398
+ if (video.requestFullscreen) {
399
+ void video.requestFullscreen().catch(() => {});
400
+ } else if (el.webkitRequestFullscreen) {
401
+ el.webkitRequestFullscreen();
402
+ } else if (el.webkitEnterFullscreen) {
403
+ el.webkitEnterFullscreen();
404
+ }
405
+ }
406
+
323
407
  #handleDialogClick = (e: Event) => {
324
408
  const target = e.target as HTMLElement;
325
409
  // Close on backdrop click (dialog itself or the content wrapper, not media/buttons)
@@ -365,24 +449,23 @@ export class JantMediaLightbox extends LitElement {
365
449
  this.querySelector<HTMLVideoElement>(".media-lightbox-video")?.pause();
366
450
  }
367
451
 
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.
452
+ // Move focus to the content wrapper on open / item change not the close
453
+ // button (its focus ring would show during arrow-key nav) and not the
454
+ // <video> (a focused <video> routes keydown to its own native handler,
455
+ // bypassing the dialog-level shortcuts in #handleVideoKeydown).
371
456
  #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
457
  this.querySelector<HTMLElement>(".media-lightbox-content")?.focus();
384
458
  }
385
459
 
460
+ // Browsers focus a <video> when it's clicked. Bounce focus back to the
461
+ // content wrapper so keydown keeps reaching the dialog-level shortcut
462
+ // handler instead of the video's native key handling.
463
+ #handleVideoFocus = () => {
464
+ this.querySelector<HTMLElement>(".media-lightbox-content")?.focus({
465
+ preventScroll: true,
466
+ });
467
+ };
468
+
386
469
  #resetShortVideoState(image?: LightboxImage) {
387
470
  this._videoCurrentTime = 0;
388
471
  this._videoDuration =
@@ -543,6 +626,7 @@ export class JantMediaLightbox extends LitElement {
543
626
  playsinline
544
627
  loop
545
628
  ?muted=${this._videoMuted}
629
+ @focus=${this.#handleVideoFocus}
546
630
  @loadedmetadata=${this.#handleShortVideoLoadedMetadata}
547
631
  @timeupdate=${this.#handleShortVideoTimeUpdate}
548
632
  ></video>
@@ -596,6 +680,7 @@ export class JantMediaLightbox extends LitElement {
596
680
  controls
597
681
  autoplay
598
682
  playsinline
683
+ @focus=${this.#handleVideoFocus}
599
684
  ></video>`
600
685
  : html`<img
601
686
  class=${`media-lightbox-img${isScrollableImage ? " media-lightbox-img-scroll" : ""}`}
@@ -0,0 +1,9 @@
1
+ import { initSlashCommandDiscovery } from "./slash-discovery.js";
2
+
3
+ if (document.readyState === "loading") {
4
+ document.addEventListener("DOMContentLoaded", () => {
5
+ initSlashCommandDiscovery();
6
+ });
7
+ } else {
8
+ initSlashCommandDiscovery();
9
+ }