@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.
- package/dist/app-BgMwEN-M.js +6 -0
- package/dist/{app-D_ke5q_u.js → app-C481ssbr.js} +45 -25
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/{client-DkbGkmgp.js → client-CJQYvkEx.js} +10 -8
- package/dist/client/_assets/client-CQvi1Buw.css +2 -0
- package/dist/client/_assets/{client-auth-BRrao4p4.js → client-auth-CfBiCAB7.js} +95 -67
- package/dist/{export-I9XFTWyO.js → export-CR9Megtb.js} +2 -2
- package/dist/{github-sync-C0Fi4LKt.js → github-sync-8Vv06aCr.js} +2 -2
- package/dist/{github-sync-DBAwA3H9.js → github-sync-DYZq9rQp.js} +1 -1
- package/dist/index.js +3 -3
- package/dist/node.js +4 -4
- package/package.json +1 -1
- package/src/client/__tests__/slash-discovery.test.ts +150 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +1 -0
- package/src/client/components/__tests__/jant-compose-editor.test.ts +1 -0
- package/src/client/components/compose-types.ts +1 -0
- package/src/client/components/jant-compose-dialog.ts +35 -6
- package/src/client/components/jant-compose-editor.ts +51 -7
- package/src/client/components/jant-media-lightbox.ts +124 -39
- package/src/client/slash-discovery-bridge.ts +9 -0
- package/src/client/slash-discovery.ts +200 -0
- package/src/client/tiptap/slash-commands.ts +5 -0
- package/src/client-auth.ts +1 -0
- package/src/i18n/locales/public/en.po +5 -0
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +5 -0
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +5 -0
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/lib/render.tsx +3 -0
- package/src/routes/api/__tests__/settings.test.ts +59 -0
- package/src/routes/api/settings.ts +19 -0
- package/src/routes/pages/new.tsx +6 -0
- package/src/services/export-theme/assets/client-site.js +9 -7
- package/src/styles/tokens.css +40 -0
- package/src/styles/ui.css +83 -7
- package/src/types/config.ts +5 -0
- package/src/types/views.ts +1 -0
- package/src/ui/compose/ComposeDialog.tsx +12 -0
- package/src/ui/layouts/SiteLayout.tsx +2 -0
- package/src/ui/pages/ComposePage.tsx +3 -0
- package/dist/app-BpyntjN7.js +0 -6
- 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
|
+
});
|
|
@@ -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)
|
|
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
|
|
5013
|
-
|
|
5014
|
-
|
|
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
|
-
${
|
|
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.
|
|
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-
|
|
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
|
-
|
|
1728
|
-
|
|
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
|
-
|
|
1794
|
-
|
|
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
|
-
//
|
|
291
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
if (
|
|
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
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
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" : ""}`}
|