@lapis-notes/workspace 0.1.0 → 0.2.1

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 (77) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/LICENSE.md +3 -3
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.js +3 -0
  5. package/dist/plugin-management/CommunityPluginsSettingsPage.svelte +152 -73
  6. package/dist/plugin-management/PluginBrowser.svelte +558 -0
  7. package/dist/plugin-management/PluginBrowser.svelte.d.ts +17 -0
  8. package/dist/plugin-management/PluginBrowserCard.svelte +230 -0
  9. package/dist/plugin-management/PluginBrowserCard.svelte.d.ts +9 -0
  10. package/dist/plugin-management/PluginBrowserDetail.svelte +1168 -0
  11. package/dist/plugin-management/PluginBrowserDetail.svelte.d.ts +20 -0
  12. package/dist/plugin-management/PluginBrowserDirectory.svelte +1069 -0
  13. package/dist/plugin-management/PluginBrowserDirectory.svelte.d.ts +21 -0
  14. package/dist/plugin-management/PluginBrowserLanding.svelte +341 -0
  15. package/dist/plugin-management/PluginBrowserLanding.svelte.d.ts +12 -0
  16. package/dist/plugin-management/PluginRegistryBadge.svelte +41 -0
  17. package/dist/plugin-management/PluginRegistryBadge.svelte.d.ts +8 -0
  18. package/dist/plugin-management/PluginRegistryContentState.svelte +80 -0
  19. package/dist/plugin-management/PluginRegistryContentState.svelte.d.ts +14 -0
  20. package/dist/plugin-management/PluginRegistryImage.svelte +64 -0
  21. package/dist/plugin-management/PluginRegistryImage.svelte.d.ts +9 -0
  22. package/dist/plugin-management/PluginRegistryMedia.svelte +223 -0
  23. package/dist/plugin-management/PluginRegistryMedia.svelte.d.ts +8 -0
  24. package/dist/plugin-management/PluginRegistryMediaViewer.svelte +155 -0
  25. package/dist/plugin-management/PluginRegistryMediaViewer.svelte.d.ts +13 -0
  26. package/dist/plugin-management/PluginRegistryRow.svelte +120 -0
  27. package/dist/plugin-management/PluginRegistryRow.svelte.d.ts +25 -0
  28. package/dist/plugin-management/PluginRegistrySettingsPage.svelte +1009 -512
  29. package/dist/plugin-management/PluginRegistryStatus.svelte +33 -0
  30. package/dist/plugin-management/PluginRegistryStatus.svelte.d.ts +8 -0
  31. package/dist/plugin-management/PluginRegistryToolbar.svelte +159 -0
  32. package/dist/plugin-management/PluginRegistryToolbar.svelte.d.ts +21 -0
  33. package/dist/plugin-management/community-presentation/CommunityChip.svelte +229 -0
  34. package/dist/plugin-management/community-presentation/CommunityChip.svelte.d.ts +4 -0
  35. package/dist/plugin-management/community-presentation/CommunityMemberAvatar.svelte +42 -0
  36. package/dist/plugin-management/community-presentation/CommunityMemberAvatar.svelte.d.ts +13 -0
  37. package/dist/plugin-management/community-presentation/RegistryMediaGallery.svelte +282 -0
  38. package/dist/plugin-management/community-presentation/RegistryMediaGallery.svelte.d.ts +8 -0
  39. package/dist/plugin-management/community-presentation/RegistryMediaViewer.css +179 -0
  40. package/dist/plugin-management/community-presentation/RegistryMediaViewer.svelte +241 -0
  41. package/dist/plugin-management/community-presentation/RegistryMediaViewer.svelte.d.ts +14 -0
  42. package/dist/plugin-management/community-presentation/RegistryPluginChangelog.svelte +216 -0
  43. package/dist/plugin-management/community-presentation/RegistryPluginChangelog.svelte.d.ts +8 -0
  44. package/dist/plugin-management/community-presentation/RegistryVerifiedImage.svelte +179 -0
  45. package/dist/plugin-management/community-presentation/RegistryVerifiedImage.svelte.d.ts +15 -0
  46. package/dist/plugin-management/community-presentation/avatar-model.d.ts +14 -0
  47. package/dist/plugin-management/community-presentation/avatar-model.js +21 -0
  48. package/dist/plugin-management/community-presentation/community-member-avatar.css +137 -0
  49. package/dist/plugin-management/community-presentation/community-typography.css +30 -0
  50. package/dist/plugin-management/community-presentation/model-adapter.d.ts +54 -0
  51. package/dist/plugin-management/community-presentation/model-adapter.js +90 -0
  52. package/dist/plugin-management/community-presentation/model.d.ts +9 -0
  53. package/dist/plugin-management/community-presentation/model.js +40 -0
  54. package/dist/plugin-management/community-presentation/props.d.ts +21 -0
  55. package/dist/plugin-management/community-presentation/props.js +1 -0
  56. package/dist/plugin-management/community-presentation/registry-filter-icons.d.ts +3 -0
  57. package/dist/plugin-management/community-presentation/registry-filter-icons.js +70 -0
  58. package/dist/plugin-management/community-presentation/registry-markdown.d.ts +1 -0
  59. package/dist/plugin-management/community-presentation/registry-markdown.js +57 -0
  60. package/dist/plugin-management/community-presentation/registry-plugin-icons.d.ts +2 -0
  61. package/dist/plugin-management/community-presentation/registry-plugin-icons.js +31 -0
  62. package/dist/plugin-management/plugin-browser-model.d.ts +44 -0
  63. package/dist/plugin-management/plugin-browser-model.js +104 -0
  64. package/dist/plugin-management/plugin-browser-navigation.d.ts +3 -0
  65. package/dist/plugin-management/plugin-browser-navigation.js +38 -0
  66. package/dist/plugin-management/plugin-download-stats.d.ts +1 -0
  67. package/dist/plugin-management/plugin-download-stats.js +15 -0
  68. package/dist/plugin-management/plugin-management.css +1088 -117
  69. package/dist/plugin-management/plugin-registry-controller.svelte.d.ts +29 -0
  70. package/dist/plugin-management/plugin-registry-controller.svelte.js +154 -0
  71. package/dist/plugin-management/plugin-registry-detail-session.svelte.d.ts +18 -0
  72. package/dist/plugin-management/plugin-registry-detail-session.svelte.js +106 -0
  73. package/dist/plugin-management/plugin-registry-detail.d.ts +17 -0
  74. package/dist/plugin-management/plugin-registry-detail.js +1 -0
  75. package/dist/plugin-management/plugins-view.svelte.d.ts +41 -0
  76. package/dist/plugin-management/plugins-view.svelte.js +109 -0
  77. package/package.json +5 -5
@@ -0,0 +1,282 @@
1
+ <script lang="ts">
2
+ import { onMount } from "svelte";
3
+
4
+ import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
5
+ import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
6
+
7
+ import type { RegistryGalleryItem } from "./model-adapter";
8
+ import RegistryMediaViewer from "./RegistryMediaViewer.svelte";
9
+ import RegistryVerifiedImage from "./RegistryVerifiedImage.svelte";
10
+
11
+ let {
12
+ items,
13
+ pluginName,
14
+ }: {
15
+ items: readonly RegistryGalleryItem[];
16
+ pluginName: string;
17
+ } = $props();
18
+
19
+ let track = $state<HTMLDivElement>();
20
+ let viewerOpen = $state(false);
21
+ let selectedIndex = $state(0);
22
+ let inlineIndex = $state(0);
23
+ let atStart = $state(true);
24
+ let atEnd = $state(false);
25
+ let opener = $state<HTMLElement | null>(null);
26
+
27
+ onMount(() => {
28
+ const observer = new ResizeObserver(updateTrackState);
29
+ if (!track) return;
30
+ observer.observe(track);
31
+ updateTrackState();
32
+ return () => observer.disconnect();
33
+ });
34
+
35
+ function show(index: number, target: HTMLElement): void {
36
+ selectedIndex = index;
37
+ opener = target;
38
+ viewerOpen = true;
39
+ }
40
+
41
+ function moveTrack(offset: -1 | 1): void {
42
+ if (!track) return;
43
+ const nextIndex = Math.min(
44
+ Math.max(inlineIndex + offset, 0),
45
+ items.length - 1,
46
+ );
47
+ const target = track.children[nextIndex] as HTMLElement | undefined;
48
+ target?.scrollIntoView({
49
+ behavior: "smooth",
50
+ block: "nearest",
51
+ inline: "start",
52
+ });
53
+ }
54
+
55
+ function updateTrackState(): void {
56
+ const currentTrack = track;
57
+ if (!currentTrack) return;
58
+ const maximum = Math.max(
59
+ 0,
60
+ currentTrack.scrollWidth - currentTrack.clientWidth,
61
+ );
62
+ atStart = currentTrack.scrollLeft <= 1;
63
+ atEnd = currentTrack.scrollLeft >= maximum - 1;
64
+ const children = [...currentTrack.children] as HTMLElement[];
65
+ inlineIndex = children.reduce(
66
+ (closest, child, index) =>
67
+ Math.abs(child.offsetLeft - currentTrack.scrollLeft) <
68
+ Math.abs(children[closest]!.offsetLeft - currentTrack.scrollLeft)
69
+ ? index
70
+ : closest,
71
+ 0,
72
+ );
73
+ }
74
+ </script>
75
+
76
+ {#if items.length > 0}
77
+ <section
78
+ class="registry-media-gallery"
79
+ aria-labelledby="registry-media-gallery-title"
80
+ >
81
+ <h2
82
+ id="registry-media-gallery-title"
83
+ class="registry-media-gallery__sr-only"
84
+ >
85
+ {pluginName} screenshots
86
+ </h2>
87
+ <div
88
+ class="registry-media-gallery__frame"
89
+ data-ui-part="registry-media-gallery-frame"
90
+ >
91
+ <div
92
+ bind:this={track}
93
+ class="registry-media-gallery__track"
94
+ class:short={items.length < 3}
95
+ aria-label={`${pluginName} screenshot carousel`}
96
+ onscroll={updateTrackState}
97
+ >
98
+ {#each items as item, index (item.id)}
99
+ <button
100
+ type="button"
101
+ class:active={index >= inlineIndex && index < inlineIndex + 2}
102
+ data-ui-part="registry-media-gallery-item"
103
+ data-state={index >= inlineIndex && index < inlineIndex + 2
104
+ ? "active"
105
+ : "preview"}
106
+ aria-label={`Open screenshot ${index + 1} of ${items.length}: ${item.alt}`}
107
+ onclick={(event) => show(index, event.currentTarget)}
108
+ >
109
+ <RegistryVerifiedImage
110
+ reference={item.images.dark}
111
+ width={item.images.dark.width}
112
+ height={item.images.dark.height}
113
+ alt=""
114
+ loading="lazy"
115
+ />
116
+ </button>
117
+ {/each}
118
+ </div>
119
+ {#if items.length > 1}
120
+ <button
121
+ class="registry-media-gallery__previous"
122
+ type="button"
123
+ aria-label="Previous screenshots"
124
+ disabled={atStart}
125
+ onclick={() => moveTrack(-1)}
126
+ >
127
+ <ChevronLeftIcon aria-hidden="true" />
128
+ </button>
129
+ <button
130
+ class="registry-media-gallery__next"
131
+ type="button"
132
+ aria-label="Next screenshots"
133
+ disabled={atEnd}
134
+ onclick={() => moveTrack(1)}
135
+ >
136
+ <ChevronRightIcon aria-hidden="true" />
137
+ </button>
138
+ {/if}
139
+ </div>
140
+ </section>
141
+
142
+ <RegistryMediaViewer
143
+ {items}
144
+ {pluginName}
145
+ {opener}
146
+ initialMode="dark"
147
+ bind:open={viewerOpen}
148
+ bind:selectedIndex
149
+ />
150
+ {/if}
151
+
152
+ <style>
153
+ .registry-media-gallery {
154
+ min-width: 0;
155
+ }
156
+ .registry-media-gallery__frame {
157
+ position: relative;
158
+ }
159
+ .registry-media-gallery__track {
160
+ display: flex;
161
+ gap: 0.5rem;
162
+ padding-inline-end: 12%;
163
+ overflow-x: auto;
164
+ scrollbar-width: none;
165
+ scroll-behavior: smooth;
166
+ scroll-snap-type: x mandatory;
167
+ overscroll-behavior-x: contain;
168
+ }
169
+ .registry-media-gallery__track.short {
170
+ padding-inline-end: 0;
171
+ }
172
+ .registry-media-gallery__track::-webkit-scrollbar {
173
+ display: none;
174
+ }
175
+ .registry-media-gallery__track > button {
176
+ position: relative;
177
+ flex: 0 0 calc(44% - 0.25rem);
178
+ overflow: hidden;
179
+ border: 0;
180
+ border-radius: 0.65rem;
181
+ padding: 0;
182
+ background: #09090b;
183
+ cursor: zoom-in;
184
+ opacity: 0.28;
185
+ scroll-snap-align: start;
186
+ scroll-snap-stop: always;
187
+ transition: opacity 200ms ease;
188
+ }
189
+ .registry-media-gallery__track.short > button {
190
+ flex-basis: calc(50% - 0.25rem);
191
+ }
192
+ .registry-media-gallery__track > button.active,
193
+ .registry-media-gallery__track > button:hover,
194
+ .registry-media-gallery__track > button:focus-visible {
195
+ opacity: 1;
196
+ }
197
+ .registry-media-gallery__track > button:focus-visible {
198
+ outline: 2px solid var(--ring);
199
+ outline-offset: -2px;
200
+ }
201
+ .registry-media-gallery__track :global(img) {
202
+ display: block;
203
+ width: 100%;
204
+ aspect-ratio: 3 / 2;
205
+ object-fit: cover;
206
+ }
207
+ .registry-media-gallery__frame > button {
208
+ position: absolute;
209
+ z-index: 2;
210
+ top: 50%;
211
+ display: grid;
212
+ width: 2rem;
213
+ height: 2rem;
214
+ place-items: center;
215
+ transform: translateY(-50%);
216
+ border: 1px solid var(--border);
217
+ border-radius: 999px;
218
+ padding: 0;
219
+ color: var(--foreground);
220
+ background: color-mix(in srgb, var(--background) 88%, transparent);
221
+ box-shadow: 0 0.2rem 1rem rgb(0 0 0 / 18%);
222
+ cursor: pointer;
223
+ backdrop-filter: blur(8px);
224
+ }
225
+ .registry-media-gallery__frame > button:disabled {
226
+ opacity: 0;
227
+ pointer-events: none;
228
+ }
229
+ .registry-media-gallery__frame > button :global(svg) {
230
+ width: 1rem;
231
+ height: 1rem;
232
+ }
233
+ .registry-media-gallery__previous {
234
+ left: 0.6rem;
235
+ }
236
+ .registry-media-gallery__next {
237
+ right: 0.6rem;
238
+ }
239
+ .registry-media-gallery__sr-only {
240
+ position: absolute;
241
+ width: 1px;
242
+ height: 1px;
243
+ padding: 0;
244
+ margin: -1px;
245
+ overflow: hidden;
246
+ clip: rect(0, 0, 0, 0);
247
+ white-space: nowrap;
248
+ border: 0;
249
+ }
250
+ @media (max-width: 45rem) {
251
+ .registry-media-gallery__track {
252
+ padding-inline-end: 0;
253
+ }
254
+ .registry-media-gallery__track > button {
255
+ flex-basis: 100%;
256
+ }
257
+ .registry-media-gallery__frame > button {
258
+ display: none;
259
+ }
260
+ }
261
+ @container plugin-browser (max-width: 45rem) {
262
+ .registry-media-gallery__track {
263
+ padding-inline-end: 0;
264
+ }
265
+ .registry-media-gallery__track > button {
266
+ flex-basis: 100%;
267
+ }
268
+ .registry-media-gallery__frame > button {
269
+ display: none;
270
+ }
271
+ }
272
+ @media (hover: none), (pointer: coarse) {
273
+ .registry-media-gallery__frame > button {
274
+ display: none;
275
+ }
276
+ }
277
+ @media (prefers-reduced-motion: reduce) {
278
+ .registry-media-gallery__track {
279
+ scroll-behavior: auto;
280
+ }
281
+ }
282
+ </style>
@@ -0,0 +1,8 @@
1
+ import type { RegistryGalleryItem } from "./model-adapter";
2
+ type $$ComponentProps = {
3
+ items: readonly RegistryGalleryItem[];
4
+ pluginName: string;
5
+ };
6
+ declare const RegistryMediaGallery: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type RegistryMediaGallery = ReturnType<typeof RegistryMediaGallery>;
8
+ export default RegistryMediaGallery;
@@ -0,0 +1,179 @@
1
+ .community-registry-media-viewer[data-ui-component="dialog"][data-ui-part="dialog-content"] {
2
+ position: fixed;
3
+ inset: 0;
4
+ top: 0;
5
+ left: 0;
6
+ box-sizing: border-box;
7
+ width: 100vw;
8
+ max-width: none;
9
+ height: 100dvh;
10
+ max-height: none;
11
+ margin: 0;
12
+ border: 0;
13
+ border-radius: 0;
14
+ padding: 0;
15
+ color: white;
16
+ background: #000;
17
+ box-shadow: none;
18
+ overflow: hidden;
19
+ translate: none;
20
+ transform: none;
21
+ }
22
+
23
+ .community-registry-media-viewer__track {
24
+ display: flex;
25
+ width: 100%;
26
+ height: 100%;
27
+ overflow-x: auto;
28
+ overflow-y: hidden;
29
+ scrollbar-width: none;
30
+ scroll-behavior: smooth;
31
+ scroll-snap-type: x mandatory;
32
+ overscroll-behavior-x: contain;
33
+ }
34
+
35
+ .community-registry-media-viewer__track::-webkit-scrollbar {
36
+ display: none;
37
+ }
38
+
39
+ .community-registry-media-viewer__slide {
40
+ display: grid;
41
+ min-width: 100%;
42
+ height: 100%;
43
+ margin: 0;
44
+ padding: clamp(4.5rem, 8vw, 6rem) clamp(1rem, 6vw, 5rem) 3rem;
45
+ place-items: center;
46
+ box-sizing: border-box;
47
+ scroll-snap-align: start;
48
+ }
49
+
50
+ .community-registry-media-viewer__slide img {
51
+ display: block;
52
+ max-width: min(93vw, 75rem);
53
+ max-height: 86dvh;
54
+ object-fit: contain;
55
+ }
56
+
57
+ .community-registry-media-viewer__modes {
58
+ position: fixed;
59
+ z-index: 2;
60
+ top: 1rem;
61
+ left: 50%;
62
+ display: flex;
63
+ transform: translateX(-50%);
64
+ border: 1px solid rgb(255 255 255 / 18%);
65
+ border-radius: calc(var(--radius) * 0.8);
66
+ background: rgb(24 24 27 / 88%);
67
+ backdrop-filter: blur(12px);
68
+ --ui-button-group-radius: calc(var(--radius) * 0.8);
69
+ }
70
+
71
+ .community-registry-media-viewer__modes [role="radio"] {
72
+ min-height: 2rem;
73
+ border: 0;
74
+ padding: 0.25rem 0.75rem;
75
+ color: rgb(255 255 255 / 68%);
76
+ background: transparent;
77
+ font: inherit;
78
+ font-size: var(--community-text-secondary);
79
+ cursor: pointer;
80
+ }
81
+
82
+ .community-registry-media-viewer__modes [role="radio"][aria-checked="true"] {
83
+ color: white;
84
+ background: rgb(255 255 255 / 14%);
85
+ }
86
+
87
+ .community-registry-media-viewer__modes [role="radio"]:focus-visible,
88
+ .community-registry-media-viewer__close:focus-visible,
89
+ .community-registry-media-viewer__previous:focus-visible,
90
+ .community-registry-media-viewer__next:focus-visible {
91
+ outline: 2px solid white;
92
+ outline-offset: 2px;
93
+ }
94
+
95
+ .community-registry-media-viewer__close,
96
+ .community-registry-media-viewer__previous,
97
+ .community-registry-media-viewer__next {
98
+ position: fixed;
99
+ z-index: 2;
100
+ display: grid;
101
+ width: 2.5rem;
102
+ height: 2.5rem;
103
+ place-items: center;
104
+ border: 1px solid rgb(255 255 255 / 18%);
105
+ border-radius: 999px;
106
+ padding: 0;
107
+ color: white;
108
+ background: rgb(24 24 27 / 78%);
109
+ cursor: pointer;
110
+ backdrop-filter: blur(12px);
111
+ }
112
+
113
+ .community-registry-media-viewer__close:hover,
114
+ .community-registry-media-viewer__previous:hover:not(:disabled),
115
+ .community-registry-media-viewer__next:hover:not(:disabled) {
116
+ color: white;
117
+ background: rgb(39 39 42 / 86%);
118
+ }
119
+
120
+ .community-registry-media-viewer__close svg,
121
+ .community-registry-media-viewer__previous svg,
122
+ .community-registry-media-viewer__next svg {
123
+ width: 1.15rem;
124
+ height: 1.15rem;
125
+ }
126
+
127
+ .community-registry-media-viewer__previous:disabled,
128
+ .community-registry-media-viewer__next:disabled {
129
+ opacity: 0.25;
130
+ cursor: default;
131
+ }
132
+
133
+ .community-registry-media-viewer__close {
134
+ top: 1rem;
135
+ right: 1rem;
136
+ }
137
+
138
+ .community-registry-media-viewer__previous,
139
+ .community-registry-media-viewer__next {
140
+ top: 50%;
141
+ transform: translateY(-50%);
142
+ }
143
+
144
+ .community-registry-media-viewer__previous {
145
+ left: 1rem;
146
+ }
147
+
148
+ .community-registry-media-viewer__next {
149
+ right: 1rem;
150
+ }
151
+
152
+ .community-registry-media-viewer__status {
153
+ position: fixed;
154
+ z-index: 2;
155
+ bottom: 0.75rem;
156
+ left: 50%;
157
+ max-width: calc(100vw - 2rem);
158
+ transform: translateX(-50%);
159
+ color: rgb(255 255 255 / 68%);
160
+ font-size: var(--community-text-secondary);
161
+ text-align: center;
162
+ }
163
+
164
+ @media (max-width: 45rem) {
165
+ .community-registry-media-viewer__previous,
166
+ .community-registry-media-viewer__next {
167
+ display: none !important;
168
+ }
169
+
170
+ .community-registry-media-viewer__slide {
171
+ padding-inline: 0.5rem;
172
+ }
173
+ }
174
+
175
+ @media (prefers-reduced-motion: reduce) {
176
+ .community-registry-media-viewer__track {
177
+ scroll-behavior: auto;
178
+ }
179
+ }
@@ -0,0 +1,241 @@
1
+ <script lang="ts">
2
+ import { tick } from "svelte";
3
+
4
+ import { Button } from "@lapismd/design-core/shadcn/button";
5
+ import * as ButtonGroup from "@lapismd/design-core/shadcn/button-group";
6
+ import * as Dialog from "@lapismd/design-core/shadcn/dialog";
7
+
8
+ import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
9
+ import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
10
+ import XIcon from "@lucide/svelte/icons/x";
11
+
12
+ import type { RegistryMediaItem } from "./model-adapter";
13
+ import RegistryVerifiedImage from "./RegistryVerifiedImage.svelte";
14
+ import "./RegistryMediaViewer.css";
15
+
16
+ type MediaMode = "preview" | "full" | "light" | "dark";
17
+ type SelectableMediaMode = Exclude<MediaMode, "preview">;
18
+
19
+ let {
20
+ items,
21
+ pluginName,
22
+ open = $bindable(false),
23
+ selectedIndex = $bindable(0),
24
+ initialMode = "full",
25
+ opener = null,
26
+ }: {
27
+ items: readonly RegistryMediaItem[];
28
+ pluginName: string;
29
+ open?: boolean;
30
+ selectedIndex?: number;
31
+ initialMode?: MediaMode;
32
+ opener?: HTMLElement | null;
33
+ } = $props();
34
+
35
+ let dialog = $state<HTMLElement | null>(null);
36
+ let track = $state<HTMLDivElement>();
37
+ let mode = $state<MediaMode>("full");
38
+ let wasOpen = false;
39
+ let scrollTarget: number | null = null;
40
+ const lastIndex = $derived(Math.max(0, items.length - 1));
41
+ const mediaModes = [
42
+ ["full", "Split"],
43
+ ["light", "Light"],
44
+ ["dark", "Dark"],
45
+ ] as const;
46
+
47
+ $effect(() => {
48
+ if (open && !wasOpen) {
49
+ selectedIndex = Math.min(Math.max(selectedIndex, 0), lastIndex);
50
+ mode = initialMode;
51
+ scrollTarget = selectedIndex;
52
+ void tick().then(() => {
53
+ scrollToSelected("auto");
54
+ });
55
+ } else if (!open && wasOpen) {
56
+ const returnTarget = opener;
57
+ requestAnimationFrame(() => returnTarget?.focus());
58
+ }
59
+ wasOpen = open;
60
+ });
61
+
62
+ function imageFor(item: RegistryMediaItem) {
63
+ return item.images[mode];
64
+ }
65
+
66
+ function setMode(nextMode: MediaMode): void {
67
+ mode = nextMode;
68
+ }
69
+
70
+ function moveTo(index: number): void {
71
+ selectedIndex = Math.min(Math.max(index, 0), lastIndex);
72
+ scrollToSelected("smooth");
73
+ }
74
+
75
+ function scrollToSelected(behavior: ScrollBehavior): void {
76
+ scrollTarget = selectedIndex;
77
+ track?.scrollTo({
78
+ left: selectedIndex * (track?.clientWidth ?? 0),
79
+ behavior,
80
+ });
81
+ }
82
+
83
+ function updateSelectedFromScroll(): void {
84
+ if (!track?.clientWidth) return;
85
+ if (scrollTarget !== null) {
86
+ if (Math.abs(track.scrollLeft - scrollTarget * track.clientWidth) > 1)
87
+ return;
88
+ scrollTarget = null;
89
+ }
90
+ selectedIndex = Math.min(
91
+ Math.max(Math.round(track.scrollLeft / track.clientWidth), 0),
92
+ lastIndex,
93
+ );
94
+ }
95
+
96
+ function handleKeydown(event: KeyboardEvent): void {
97
+ const modeButton = (event.target as HTMLElement).closest(
98
+ "button[data-media-mode]",
99
+ );
100
+ if (
101
+ modeButton &&
102
+ (event.key === "ArrowLeft" || event.key === "ArrowRight")
103
+ ) {
104
+ event.preventDefault();
105
+ const modes: SelectableMediaMode[] = ["full", "light", "dark"];
106
+ const offset = event.key === "ArrowLeft" ? -1 : 1;
107
+ const focusedMode = modeButton.getAttribute(
108
+ "data-media-mode",
109
+ ) as SelectableMediaMode;
110
+ const next =
111
+ (modes.indexOf(focusedMode) + offset + modes.length) % modes.length;
112
+ setMode(modes[next]!);
113
+ const buttons =
114
+ dialog?.querySelectorAll<HTMLButtonElement>("[data-media-mode]");
115
+ buttons?.[next]?.focus();
116
+ return;
117
+ }
118
+
119
+ if (event.key === "ArrowLeft") {
120
+ event.preventDefault();
121
+ moveTo(selectedIndex - 1);
122
+ } else if (event.key === "ArrowRight") {
123
+ event.preventDefault();
124
+ moveTo(selectedIndex + 1);
125
+ } else if (event.key === "Home") {
126
+ event.preventDefault();
127
+ moveTo(0);
128
+ } else if (event.key === "End") {
129
+ event.preventDefault();
130
+ moveTo(lastIndex);
131
+ }
132
+ }
133
+ </script>
134
+
135
+ <Dialog.Root bind:open>
136
+ {#if open}
137
+ <Dialog.Content
138
+ bind:ref={dialog}
139
+ class="community-registry-media-viewer"
140
+ showCloseButton={false}
141
+ data-media-mode={mode}
142
+ onkeydown={handleKeydown}
143
+ >
144
+ <Dialog.Title class="sr-only">
145
+ {pluginName} media viewer
146
+ </Dialog.Title>
147
+ <ButtonGroup.Root
148
+ role="radiogroup"
149
+ class="community-registry-media-viewer__modes"
150
+ aria-label="Image theme"
151
+ >
152
+ {#each mediaModes as [value, label]}
153
+ <Button
154
+ type="button"
155
+ variant={mode === value ? "secondary" : "ghost"}
156
+ size="sm"
157
+ role="radio"
158
+ aria-checked={mode === value}
159
+ data-media-mode={value}
160
+ onclick={() => setMode(value)}
161
+ >
162
+ {label}
163
+ </Button>
164
+ {/each}
165
+ </ButtonGroup.Root>
166
+ <Button
167
+ class="community-registry-media-viewer__close"
168
+ type="button"
169
+ variant="ghost"
170
+ size="icon"
171
+ aria-label="Close media viewer"
172
+ onclick={() => {
173
+ open = false;
174
+ }}
175
+ >
176
+ <XIcon aria-hidden="true" />
177
+ </Button>
178
+
179
+ <div
180
+ bind:this={track}
181
+ class="community-registry-media-viewer__track"
182
+ role="listbox"
183
+ aria-label={`${pluginName} media`}
184
+ tabindex="0"
185
+ onscroll={updateSelectedFromScroll}
186
+ onwheel={() => (scrollTarget = null)}
187
+ onpointerdown={() => (scrollTarget = null)}
188
+ onkeydown={(event) => {
189
+ event.stopPropagation();
190
+ handleKeydown(event);
191
+ }}
192
+ >
193
+ {#each items as item, index}
194
+ {@const image = imageFor(item)}
195
+ <div
196
+ class="community-registry-media-viewer__slide"
197
+ role="option"
198
+ aria-selected={index === selectedIndex}
199
+ >
200
+ <RegistryVerifiedImage
201
+ reference={image}
202
+ alt={item.alt}
203
+ width={image.width}
204
+ height={image.height}
205
+ loading={index === selectedIndex ? "eager" : "lazy"}
206
+ />
207
+ </div>
208
+ {/each}
209
+ </div>
210
+
211
+ {#if items.length > 1}
212
+ <Button
213
+ class="community-registry-media-viewer__previous"
214
+ type="button"
215
+ variant="ghost"
216
+ size="icon"
217
+ aria-label="Previous image"
218
+ disabled={selectedIndex === 0}
219
+ onclick={() => moveTo(selectedIndex - 1)}
220
+ >
221
+ <ChevronLeftIcon aria-hidden="true" />
222
+ </Button>
223
+ <Button
224
+ class="community-registry-media-viewer__next"
225
+ type="button"
226
+ variant="ghost"
227
+ size="icon"
228
+ aria-label="Next image"
229
+ disabled={selectedIndex === lastIndex}
230
+ onclick={() => moveTo(selectedIndex + 1)}
231
+ >
232
+ <ChevronRightIcon aria-hidden="true" />
233
+ </Button>
234
+ {/if}
235
+
236
+ <p class="community-registry-media-viewer__status" aria-live="polite">
237
+ {selectedIndex + 1} of {items.length}: {items[selectedIndex]?.alt}
238
+ </p>
239
+ </Dialog.Content>
240
+ {/if}
241
+ </Dialog.Root>