@kirchdev/duxt 0.3.4 → 0.3.5-dev-main.43-3310f4b
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/README.md +24 -9
- package/app/app.vue +17 -11
- package/app/assets/css/duxt.css +47 -6
- package/app/components/DuxtAnnouncements.vue +117 -0
- package/app/components/DuxtBrunoEntries.vue +62 -0
- package/app/components/DuxtCodeBlock.vue +18 -34
- package/app/components/DuxtCodeToolbar.vue +14 -0
- package/app/components/DuxtCopyButton.vue +33 -0
- package/app/components/DuxtCopyPage.vue +32 -30
- package/app/components/DuxtFooter.vue +39 -8
- package/app/components/DuxtHeader.vue +107 -48
- package/app/components/DuxtLandingProse.vue +1 -1
- package/app/components/DuxtLinkLabel.vue +36 -0
- package/app/components/DuxtLiveWindow.vue +8 -8
- package/app/components/DuxtLocale.vue +10 -1
- package/app/components/DuxtNavigation.vue +2 -2
- package/app/components/DuxtOpenApiClient.vue +285 -90
- package/app/components/DuxtOpenApiMedia.vue +30 -18
- package/app/components/DuxtOpenApiSchema.vue +10 -7
- package/app/components/DuxtPageFeedback.vue +7 -0
- package/app/components/DuxtPageInfo.vue +15 -12
- package/app/components/DuxtPageNav.vue +41 -27
- package/app/components/DuxtPreviewSkeleton.vue +4 -4
- package/app/components/DuxtProgress.vue +2 -14
- package/app/components/DuxtSearch.vue +230 -90
- package/app/components/DuxtSearchInput.vue +70 -0
- package/app/components/DuxtSections.vue +11 -1
- package/app/components/DuxtSegmented.vue +64 -0
- package/app/components/DuxtShortcuts.vue +16 -11
- package/app/components/DuxtShortcutsTrigger.vue +49 -0
- package/app/components/DuxtSkipLink.vue +1 -1
- package/app/components/DuxtToc.vue +16 -10
- package/app/components/DuxtTranslationBanner.vue +1 -1
- package/app/components/DuxtVersion.vue +53 -12
- package/app/components/DuxtVersionBanner.vue +5 -5
- package/app/components/content/AccordionItem.vue +2 -2
- package/app/components/content/BrunoOverview.vue +260 -0
- package/app/components/content/BrunoRequest.vue +141 -0
- package/app/components/content/BrunoRequests.vue +104 -0
- package/app/components/content/Callout.vue +5 -5
- package/app/components/content/ChangelogReleases.vue +161 -40
- package/app/components/content/CodeGroup.vue +41 -37
- package/app/components/content/Field.vue +1 -1
- package/app/components/content/FileTree.vue +1 -1
- package/app/components/content/OpenApiOperation.vue +4 -4
- package/app/components/content/OpenApiOperations.vue +1 -1
- package/app/components/content/OpenApiOverview.vue +1 -1
- package/app/components/content/PackageManagers.vue +42 -58
- package/app/components/content/PageCards.vue +7 -4
- package/app/components/content/Preview.vue +15 -39
- package/app/components/content/ProseImg.vue +596 -49
- package/app/components/content/Since.vue +1 -1
- package/app/components/content/Steps.vue +6 -4
- package/app/components/content/TabGroup.vue +13 -12
- package/app/components/ui/breadcrumb/BreadcrumbSeparator.vue +1 -1
- package/app/components/ui/command/CommandShortcut.vue +1 -1
- package/app/components/ui/dialog/DialogContent.vue +2 -2
- package/app/components/ui/dialog/DialogHeader.vue +1 -1
- package/app/components/ui/dialog/DialogScrollContent.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +2 -2
- package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +2 -2
- package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +2 -2
- package/app/components/ui/navigation-menu/NavigationMenuContent.vue +1 -1
- package/app/components/ui/navigation-menu/NavigationMenuTrigger.vue +1 -1
- package/app/components/ui/navigation-menu/NavigationMenuViewport.vue +1 -1
- package/app/components/ui/scroll-area/ScrollBar.vue +1 -1
- package/app/components/ui/select/SelectItem.vue +2 -2
- package/app/components/ui/sheet/SheetContent.vue +29 -3
- package/app/components/ui/sidebar/Sidebar.vue +28 -4
- package/app/components/ui/sidebar/SidebarGroupAction.vue +1 -1
- package/app/components/ui/sidebar/SidebarInset.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuAction.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuBadge.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuSub.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuSubButton.vue +1 -1
- package/app/components/ui/sidebar/SidebarProvider.vue +7 -16
- package/app/components/ui/sidebar/index.ts +10 -2
- package/app/components/ui/sidebar/utils.ts +0 -1
- package/app/components/ui/sonner/Sonner.vue +18 -0
- package/app/components/ui/tabs/TabsList.vue +13 -3
- package/app/components/ui/tabs/TabsTrigger.vue +24 -3
- package/app/components/ui/tooltip/TooltipContent.vue +2 -2
- package/app/composables/useActiveHeading.ts +3 -25
- package/app/composables/useDuxtAnalytics.ts +50 -0
- package/app/composables/useDuxtAnimatedHeight.ts +8 -38
- package/app/composables/useDuxtAnnouncements.ts +153 -0
- package/app/composables/useDuxtCopy.ts +51 -0
- package/app/composables/useDuxtDirection.ts +23 -0
- package/app/composables/useDuxtNavigation.ts +28 -8
- package/app/composables/useDuxtPath.ts +3 -5
- package/app/composables/useDuxtSearch.ts +87 -35
- package/app/composables/useDuxtSection.ts +15 -1
- package/app/composables/useDuxtSectionRow.ts +6 -5
- package/app/composables/useDuxtShortcuts.ts +319 -20
- package/app/composables/useDuxtVersion.ts +57 -6
- package/app/composables/useDuxtViewportMeasure.ts +37 -0
- package/app/composables/useFuzzySearch.ts +9 -4
- package/app/composables/useRecentPages.ts +14 -17
- package/app/error.vue +112 -74
- package/app/layouts/changelog.vue +9 -1
- package/app/layouts/default.vue +9 -0
- package/app/layouts/docs.vue +9 -1
- package/app/layouts/reference.vue +9 -1
- package/app/middleware/duxt-section-layout.global.ts +1 -3
- package/app/pages/[...slug].vue +146 -35
- package/app/pages/index.vue +15 -23
- package/app/types/duxt.d.ts +312 -4
- package/app/utils/analytics.ts +91 -0
- package/app/utils/announcements.ts +205 -0
- package/app/utils/changelog.ts +0 -28
- package/app/utils/code-highlight.ts +3 -1
- package/app/utils/contributor-avatar.ts +27 -0
- package/app/utils/direction.ts +43 -0
- package/app/utils/duxt-config.ts +43 -0
- package/app/utils/duxt-text.ts +10 -0
- package/app/utils/format-date.ts +28 -0
- package/app/utils/generated-sections.ts +68 -12
- package/app/utils/generated-toc.ts +14 -7
- package/app/utils/locale-path.ts +43 -0
- package/app/utils/navigation-tree.ts +51 -1
- package/app/utils/nearest-page.ts +8 -0
- package/app/utils/openapi.ts +16 -11
- package/app/utils/page-controls.ts +215 -0
- package/app/utils/pill.ts +61 -0
- package/app/utils/prose-image.ts +128 -0
- package/app/utils/search-context.ts +110 -0
- package/app/utils/search-display.ts +248 -0
- package/app/utils/section-scope.ts +65 -4
- package/app/utils/stored-json.ts +31 -0
- package/app/utils/version-choices.ts +6 -13
- package/app/utils/version-paths.ts +21 -0
- package/bin/duxt-cache-key.mjs +61 -0
- package/bin/duxt-og-cache.mjs +58 -0
- package/bin/{duxt-report.mjs → duxt.mjs} +13 -7
- package/bruno-model.ts +169 -0
- package/bruno-parse.ts +656 -0
- package/bruno-zip.ts +195 -0
- package/cli.ts +240 -0
- package/duxt-app-config.ts +18 -1
- package/frontmatter.ts +49 -0
- package/git-contributors.ts +322 -0
- package/i18n/locales/de/duxt/announcement.json +7 -0
- package/i18n/locales/de/duxt/bruno.json +25 -0
- package/i18n/locales/de/duxt/changelog.json +1 -1
- package/i18n/locales/de/duxt/code.json +2 -1
- package/i18n/locales/de/duxt/page.json +9 -0
- package/i18n/locales/de/duxt/shortcuts.json +1 -0
- package/i18n/locales/de/duxt/theme.json +2 -1
- package/i18n/locales/en/duxt/announcement.json +7 -0
- package/i18n/locales/en/duxt/bruno.json +25 -0
- package/i18n/locales/en/duxt/changelog.json +1 -1
- package/i18n/locales/en/duxt/code.json +2 -1
- package/i18n/locales/en/duxt/page.json +9 -0
- package/i18n/locales/en/duxt/shortcuts.json +1 -0
- package/i18n/locales/en/duxt/theme.json +2 -1
- package/i18n/locales/es/duxt/announcement.json +7 -0
- package/i18n/locales/es/duxt/bruno.json +25 -0
- package/i18n/locales/es/duxt/changelog.json +1 -1
- package/i18n/locales/es/duxt/code.json +2 -1
- package/i18n/locales/es/duxt/page.json +9 -0
- package/i18n/locales/es/duxt/shortcuts.json +1 -0
- package/i18n/locales/es/duxt/theme.json +2 -1
- package/i18n/locales/fr/duxt/announcement.json +7 -0
- package/i18n/locales/fr/duxt/bruno.json +25 -0
- package/i18n/locales/fr/duxt/changelog.json +1 -1
- package/i18n/locales/fr/duxt/code.json +2 -1
- package/i18n/locales/fr/duxt/page.json +9 -0
- package/i18n/locales/fr/duxt/shortcuts.json +1 -0
- package/i18n/locales/fr/duxt/theme.json +2 -1
- package/i18n/locales/pt/duxt/announcement.json +7 -0
- package/i18n/locales/pt/duxt/bruno.json +25 -0
- package/i18n/locales/pt/duxt/changelog.json +1 -1
- package/i18n/locales/pt/duxt/code.json +2 -1
- package/i18n/locales/pt/duxt/page.json +9 -0
- package/i18n/locales/pt/duxt/shortcuts.json +1 -0
- package/i18n/locales/pt/duxt/theme.json +2 -1
- package/modules/bruno.ts +118 -0
- package/modules/config.ts +26 -0
- package/modules/devtools.ts +2 -3
- package/modules/git-meta.ts +16 -47
- package/modules/search-records.ts +132 -0
- package/nuxt.config.ts +81 -7
- package/og-image-cache.ts +742 -0
- package/package.json +41 -9
- package/public/devtools/checks.html +2 -2
- package/public/devtools/config.html +59 -4
- package/public/devtools/pages.html +8 -1
- package/public/devtools/paths.html +1 -1
- package/public/devtools/sources.html +2 -2
- package/public/devtools/versions.html +3 -0
- package/report.ts +28 -19
- package/search-records.ts +380 -0
- package/section-input.ts +142 -0
- package/section-markdown.ts +67 -0
- package/section-reports.ts +10 -4
- package/sections-bruno.ts +571 -0
- package/sections-changelog.ts +76 -43
- package/sections-openapi.ts +12 -83
- package/sections-resolve.ts +174 -8
- package/sections.ts +33 -10
- package/server/devtools/shell.ts +1 -1
- package/server/mcp/tools/list-pages.ts +77 -36
- package/server/mcp/tools/list-versions.ts +84 -0
- package/server/mcp/tools/read-page.ts +80 -26
- package/server/mcp/tools/search-docs.ts +87 -53
- package/server/middleware/raw-markdown.ts +1 -1
- package/server/routes/llms-full.txt.get.ts +6 -8
- package/server/routes/llms.txt.get.ts +1 -1
- package/server/utils/duxt-server-text.ts +0 -13
- package/server/utils/llms-pages.ts +24 -9
- package/server/utils/mcp-docs.ts +577 -0
- package/sources-cache.ts +376 -0
- package/sources-git.ts +186 -31
- package/sources-resolve.ts +59 -1
- package/sources.ts +122 -13
- package/tfplugindocs.ts +158 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
* An image in a page: theme-aware, and zoomable.
|
|
3
|
+
* An image in a page: theme-aware, responsive, and zoomable.
|
|
4
4
|
*
|
|
5
5
|
* TWO VARIANTS. A screenshot of a dark editor on a white page — or the reverse
|
|
6
6
|
* — is the most common ugly thing in documentation, and CSS cannot fix it
|
|
@@ -15,25 +15,461 @@
|
|
|
15
15
|
* `<picture>` with `prefers-color-scheme` would ignore the site's own toggle,
|
|
16
16
|
* which is a class on `<html>` and not a media query.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* TWO REQUESTS, NOT ONE FILE. The page and the dialog ask for different widths
|
|
19
|
+
* — `prose-image.ts` holds both policies and the reasoning. Until they existed
|
|
20
|
+
* `NuxtImg` emitted a bare `src` with no `srcset`, so a 2400px screenshot was
|
|
21
|
+
* downloaded whole onto a phone to be drawn into a 48rem column, and the dialog
|
|
22
|
+
* showing it at full resolution was an accident of there being only one file.
|
|
23
|
+
* A page overrides the in-page policy with `sizes`; the dialog keeps its own,
|
|
24
|
+
* because a policy describing the prose column cannot describe a dialog.
|
|
25
|
+
*
|
|
26
|
+
* ZOOM, AND SOMETHING THAT SAYS SO. A screenshot scaled to a 48rem column is
|
|
27
|
+
* often unreadable, and the browser's own zoom is not the answer when the image
|
|
28
|
+
* is 2400px wide. Clicking opens it larger; Escape and a click outside close
|
|
29
|
+
* it, both from the Dialog primitive rather than hand-rolled.
|
|
30
|
+
*
|
|
31
|
+
* The only affordance used to be `cursor-zoom-in`, which does not exist on a
|
|
32
|
+
* touch device and is invisible in a screenshot of the page. The indicator is
|
|
33
|
+
* therefore PERSISTENT rather than shown on hover: hover is the half of the
|
|
34
|
+
* problem that already worked. It is decorative — `aria-hidden`, inside the one
|
|
35
|
+
* button, never a second control — because a screen reader is told what the
|
|
36
|
+
* button does by its label, and two focus stops for one image is worse than
|
|
37
|
+
* none.
|
|
38
|
+
*
|
|
39
|
+
* ITS SURFACE IS OPAQUE, AND THAT IS THE WHOLE REASON. The chip used to be
|
|
40
|
+
* `bg-background/75`, which let arbitrary image pixels through behind the
|
|
41
|
+
* glyph — and a contrast ratio against an unknown photograph is not a ratio at
|
|
42
|
+
* all. Opaque, the pair is `--muted-foreground` on `--background`, which
|
|
43
|
+
* `tests/contrast.test.ts` already measures; the border is what separates the
|
|
44
|
+
* chip itself from whatever it sits on. `check:a11y` cannot judge either
|
|
45
|
+
* (jsdom has no computed colour), so the guarantee has to come from the
|
|
46
|
+
* palette rather than from the rendered page.
|
|
47
|
+
*
|
|
48
|
+
* `zoom="false"` has to leave an image looking inert: no indicator, no ring,
|
|
49
|
+
* no cursor, and no dialog to tab into.
|
|
50
|
+
*
|
|
51
|
+
* THE VIEWER IS A LIGHTBOX, NOT A CARD. It used to be the default dialog — a
|
|
52
|
+
* bordered box with a title bar — and a picture framed by chrome reads as a
|
|
53
|
+
* form, not as the picture. It now takes the whole viewport over a blurred,
|
|
54
|
+
* darkened page: controls float in a row of their own ABOVE the image and the
|
|
55
|
+
* alt text sits BELOW it, so neither ever covers a pixel (`check:images`
|
|
56
|
+
* measures exactly that). The surface is dark in both colour modes, which is
|
|
57
|
+
* why its controls are white-on-translucent rather than theme tokens — a
|
|
58
|
+
* lightbox that turned white in light mode would glare.
|
|
59
|
+
*
|
|
60
|
+
* It ZOOMS, because a screenshot fitted to a screen is still often too small to
|
|
61
|
+
* read: the wheel zooms around the cursor, a pinch around the fingers, a click
|
|
62
|
+
* on the image to 200% at that point and back, `+`, `-` and `0` from the
|
|
63
|
+
* keyboard, and a zoomed image is dragged to pan. The toolbar says the level
|
|
64
|
+
* and resets it. Clicking the empty space around the image closes, as every
|
|
65
|
+
* viewer a reader already knows does. The original opens in a tab of its own —
|
|
66
|
+
* the one the current theme shows.
|
|
22
67
|
*/
|
|
23
|
-
const props =
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
68
|
+
const props = withDefaults(
|
|
69
|
+
defineProps<{
|
|
70
|
+
src?: string;
|
|
71
|
+
alt?: string;
|
|
72
|
+
width?: string | number;
|
|
73
|
+
height?: string | number;
|
|
74
|
+
/** A second file for dark mode. */
|
|
75
|
+
dark?: string;
|
|
76
|
+
/** `false` turns the zoom off for a decorative image. */
|
|
77
|
+
zoom?: boolean | string;
|
|
78
|
+
/**
|
|
79
|
+
* The in-page policy, in `@nuxt/image`'s own `sizes` syntax — `sm:50vw
|
|
80
|
+
* md:400px`. For the image that is NOT as wide as the prose column: a logo,
|
|
81
|
+
* a badge, a diagram set in a margin.
|
|
82
|
+
*/
|
|
83
|
+
sizes?: string;
|
|
84
|
+
/**
|
|
85
|
+
* A CSS colour behind the image — for a transparent vector or PNG whose
|
|
86
|
+
* ink disappears on the site's own background. Any colour value works,
|
|
87
|
+
* `var(--card)` included.
|
|
88
|
+
*/
|
|
89
|
+
background?: string;
|
|
90
|
+
/** The same under the dark theme; falls back to `background`. */
|
|
91
|
+
darkBackground?: string;
|
|
92
|
+
}>(),
|
|
93
|
+
{
|
|
94
|
+
/**
|
|
95
|
+
* ZOOM IS ON UNLESS A PAGE TURNS IT OFF, AND SAYING SO IS NOT OPTIONAL.
|
|
96
|
+
*
|
|
97
|
+
* Vue casts an ABSENT prop whose declared type includes `Boolean` to
|
|
98
|
+
* `false` rather than to `undefined`. `zoom?: boolean | string` compiles to
|
|
99
|
+
* `type: [Boolean, String]`, so every image that did not write `zoom="true"`
|
|
100
|
+
* arrived here with `zoom === false` — and the zoom, the indicator, the
|
|
101
|
+
* focus ring and the dialog were all switched off on every image in the
|
|
102
|
+
* site. It survived from the day the component was written because nothing
|
|
103
|
+
* in the repository rendered one: every `![…]` in the reference sits inside
|
|
104
|
+
* a code block. `www/demo/docs/3.images.md` and `check:images` are the two
|
|
105
|
+
* halves of making sure it cannot again.
|
|
106
|
+
*
|
|
107
|
+
* A default is what suppresses the cast — `isAbsent && !hasDefault` is the
|
|
108
|
+
* condition on it — so this line is load-bearing, not documentation.
|
|
109
|
+
*/
|
|
110
|
+
zoom: true
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const { t } = useI18n();
|
|
33
115
|
|
|
34
116
|
const open = ref(false);
|
|
35
117
|
|
|
36
|
-
const zoomable = computed(
|
|
118
|
+
const zoomable = computed(
|
|
119
|
+
() => Boolean(props.src) && props.zoom !== false && props.zoom !== 'false'
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const pageSizes = computed(() => proseImageSizes(props.sizes));
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The light file and its dark twin, each carrying whether a provider may touch
|
|
126
|
+
* it.
|
|
127
|
+
*
|
|
128
|
+
* A list rather than four near-identical blocks in the template, and it is the
|
|
129
|
+
* same list in the page and in the dialog — the two differ only in their sizing
|
|
130
|
+
* policy and their classes, which is the point.
|
|
131
|
+
*/
|
|
132
|
+
type Variant = { src: string; theme: string; plain: boolean };
|
|
133
|
+
|
|
134
|
+
const variants = computed<Variant[]>(() => {
|
|
135
|
+
const list: Variant[] = [];
|
|
136
|
+
|
|
137
|
+
if (props.src) {
|
|
138
|
+
list.push({
|
|
139
|
+
src: props.src,
|
|
140
|
+
// Hidden under the dark theme only when there is something to swap to.
|
|
141
|
+
theme: props.dark ? 'block dark:hidden' : '',
|
|
142
|
+
plain: proseImagePassThrough(props.src)
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (props.dark) {
|
|
147
|
+
list.push({
|
|
148
|
+
src: props.dark,
|
|
149
|
+
theme: 'hidden dark:block',
|
|
150
|
+
plain: proseImagePassThrough(props.dark)
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return list;
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* What a screen reader hears on the button.
|
|
159
|
+
*
|
|
160
|
+
* Two keys rather than one with an empty interpolation: a decorative image
|
|
161
|
+
* carries no alt text, and "Enlarge image: " read aloud is worse than
|
|
162
|
+
* "Enlarge image".
|
|
163
|
+
*/
|
|
164
|
+
const label = computed(() =>
|
|
165
|
+
props.alt
|
|
166
|
+
? t('duxt.page.image.zoomNamed', { alt: props.alt })
|
|
167
|
+
: t('duxt.page.image.zoom')
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* COLOURS, NOT CLASSES. A page cannot pass Tailwind classes to an image: the
|
|
172
|
+
* layer's stylesheet is compiled from `app/` alone, never from Markdown, so a
|
|
173
|
+
* `dark:bg-…` written in a page would be a class no CSS exists for — silently.
|
|
174
|
+
* The values travel as custom properties instead, and the one class that reads
|
|
175
|
+
* them lives here, where Tailwind sees it.
|
|
176
|
+
*
|
|
177
|
+
* Applied to the image in the page AND in the viewer: a transparent diagram is
|
|
178
|
+
* just as unreadable on the viewer's dark surface.
|
|
179
|
+
*/
|
|
180
|
+
const backdrop = computed(() => {
|
|
181
|
+
if (!props.background && !props.darkBackground) return undefined;
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
class: 'bg-(--duxt-img-bg) dark:bg-(--duxt-img-bg-dark)',
|
|
185
|
+
style: {
|
|
186
|
+
'--duxt-img-bg': props.background ?? 'transparent',
|
|
187
|
+
'--duxt-img-bg-dark': props.darkBackground ?? props.background
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
/** The dialog is named by the image, and by what it is only when the image is unnamed. */
|
|
193
|
+
const title = computed(() => props.alt || t('duxt.page.image.zoom'));
|
|
194
|
+
|
|
195
|
+
const colorMode = useColorMode();
|
|
196
|
+
|
|
197
|
+
/** The file the reader is looking at — the dark twin only under the dark theme. */
|
|
198
|
+
const original = computed(() =>
|
|
199
|
+
props.dark && colorMode.value === 'dark' ? props.dark : props.src
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* ZOOM IS ONE SCALE AND ONE OFFSET, applied as a transform on the picture.
|
|
204
|
+
*
|
|
205
|
+
* A transform rather than a larger box in a scroller, for two reasons: it zooms
|
|
206
|
+
* around a POINT — the cursor, the fingers — which a scroller can only fake by
|
|
207
|
+
* chasing its own scroll position, and it never changes layout, so the image
|
|
208
|
+
* keeps the fitted box `check:images` measures. The offset is kept inside the
|
|
209
|
+
* image's reach, so a picture can be panned to its edge but never off-screen.
|
|
210
|
+
*
|
|
211
|
+
* Reset on every open: a viewer that remembered the last image's zoom would
|
|
212
|
+
* open the next one already cropped.
|
|
213
|
+
*/
|
|
214
|
+
/**
|
|
215
|
+
* The levels `+` and `-` stop at — round numbers, because the toolbar prints
|
|
216
|
+
* the level and 338% reads as a bug. The wheel and a pinch stay continuous,
|
|
217
|
+
* which is what a hand expects; the next button press snaps back onto a step.
|
|
218
|
+
*/
|
|
219
|
+
const ZOOM_STEPS = [1, 1.5, 2, 3, 4, 6, 8];
|
|
220
|
+
const MAX_SCALE = ZOOM_STEPS.at(-1)!;
|
|
221
|
+
const ZOOM_CLICK = 2;
|
|
222
|
+
|
|
223
|
+
const scale = ref(1);
|
|
224
|
+
const offset = ref({ x: 0, y: 0 });
|
|
225
|
+
const dragging = ref(false);
|
|
226
|
+
const zoomed = computed(() => scale.value > 1);
|
|
227
|
+
const stage = useTemplateRef<HTMLElement>('stage');
|
|
228
|
+
|
|
229
|
+
watch(open, resetZoom);
|
|
230
|
+
|
|
231
|
+
function resetZoom(): void {
|
|
232
|
+
scale.value = 1;
|
|
233
|
+
offset.value = { x: 0, y: 0 };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Keep the picture's edge from leaving its side of the stage.
|
|
238
|
+
*
|
|
239
|
+
* `offsetWidth` is the laid-out box, untouched by the transform — so this is
|
|
240
|
+
* the scale-1 size whatever the current zoom. The hidden theme twin measures 0
|
|
241
|
+
* and drops out of the `max`.
|
|
242
|
+
*/
|
|
243
|
+
function clamp(x: number, y: number, next: number) {
|
|
244
|
+
const el = stage.value;
|
|
245
|
+
if (!el) return { x, y };
|
|
246
|
+
|
|
247
|
+
const images = [...el.querySelectorAll<HTMLElement>('[data-viewer-image]')];
|
|
248
|
+
const width = Math.max(0, ...images.map((image) => image.offsetWidth));
|
|
249
|
+
const height = Math.max(0, ...images.map((image) => image.offsetHeight));
|
|
250
|
+
const maxX = Math.max(0, (width * next - el.clientWidth) / 2);
|
|
251
|
+
const maxY = Math.max(0, (height * next - el.clientHeight) / 2);
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
x: Math.min(maxX, Math.max(-maxX, x)),
|
|
255
|
+
y: Math.min(maxY, Math.max(-maxY, y))
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A viewport point, relative to the centre of the stage the image is centred in. */
|
|
260
|
+
function fromClient(clientX: number, clientY: number) {
|
|
261
|
+
const rect = stage.value?.getBoundingClientRect();
|
|
262
|
+
if (!rect) return { x: 0, y: 0 };
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
x: clientX - rect.x - rect.width / 2,
|
|
266
|
+
y: clientY - rect.y - rect.height / 2
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The next step above the current level, or below it — strictly, so a level
|
|
272
|
+
* the wheel left between two steps moves to the nearer one in that direction
|
|
273
|
+
* rather than skipping it.
|
|
274
|
+
*/
|
|
275
|
+
function stepZoom(direction: 1 | -1): void {
|
|
276
|
+
const current = scale.value;
|
|
277
|
+
const next =
|
|
278
|
+
direction === 1
|
|
279
|
+
? ZOOM_STEPS.find((step) => step > current + 0.001)
|
|
280
|
+
: ZOOM_STEPS.findLast((step) => step < current - 0.001);
|
|
281
|
+
|
|
282
|
+
zoomTo(next ?? (direction === 1 ? MAX_SCALE : 1));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Zoom to `next`, keeping the picture's pixel under `point` where it is. */
|
|
286
|
+
function zoomTo(next: number, point = { x: 0, y: 0 }): void {
|
|
287
|
+
const target = Math.min(MAX_SCALE, Math.max(1, next));
|
|
288
|
+
const ratio = target / scale.value;
|
|
289
|
+
|
|
290
|
+
scale.value = target;
|
|
291
|
+
offset.value =
|
|
292
|
+
target === 1
|
|
293
|
+
? { x: 0, y: 0 }
|
|
294
|
+
: clamp(
|
|
295
|
+
point.x - (point.x - offset.value.x) * ratio,
|
|
296
|
+
point.y - (point.y - offset.value.y) * ratio,
|
|
297
|
+
target
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function onWheel(event: WheelEvent): void {
|
|
302
|
+
// Line-mode wheels report in lines, not pixels.
|
|
303
|
+
const delta = event.deltaY * (event.deltaMode === 1 ? 16 : 1);
|
|
304
|
+
zoomTo(
|
|
305
|
+
scale.value * Math.exp(-delta * 0.0015),
|
|
306
|
+
fromClient(event.clientX, event.clientY)
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function onKeydown(event: KeyboardEvent): void {
|
|
311
|
+
if (event.key === '+' || event.key === '=') stepZoom(1);
|
|
312
|
+
else if (event.key === '-') stepZoom(-1);
|
|
313
|
+
else if (event.key === '0') resetZoom();
|
|
314
|
+
else return;
|
|
315
|
+
|
|
316
|
+
event.preventDefault();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Drag to pan, two fingers to pinch.
|
|
321
|
+
*
|
|
322
|
+
* `moved` is what tells a drag from a click: a pan that ends over the empty
|
|
323
|
+
* stage must not close the viewer, and one that ends over the image must not
|
|
324
|
+
* zoom it. The pointer is captured only once it has MOVED — captured from
|
|
325
|
+
* `pointerdown`, every click would be retargeted to the stage and a click on
|
|
326
|
+
* the image could no longer be told from a click beside it.
|
|
327
|
+
*/
|
|
328
|
+
const pointers = new Map<number, { x: number; y: number }>();
|
|
329
|
+
let pinch: { distance: number; scale: number } | undefined;
|
|
330
|
+
let travel = 0;
|
|
331
|
+
let moved = false;
|
|
332
|
+
|
|
333
|
+
function onPointerDown(event: PointerEvent): void {
|
|
334
|
+
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
|
335
|
+
|
|
336
|
+
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
337
|
+
travel = 0;
|
|
338
|
+
moved = false;
|
|
339
|
+
|
|
340
|
+
if (pointers.size === 2) {
|
|
341
|
+
const [a, b] = [...pointers.values()];
|
|
342
|
+
pinch = {
|
|
343
|
+
distance: Math.hypot(a!.x - b!.x, a!.y - b!.y),
|
|
344
|
+
scale: scale.value
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function onPointerMove(event: PointerEvent): void {
|
|
350
|
+
const previous = pointers.get(event.pointerId);
|
|
351
|
+
if (!previous) return;
|
|
352
|
+
|
|
353
|
+
const now = { x: event.clientX, y: event.clientY };
|
|
354
|
+
pointers.set(event.pointerId, now);
|
|
355
|
+
|
|
356
|
+
if (pinch && pointers.size === 2) {
|
|
357
|
+
const [a, b] = [...pointers.values()];
|
|
358
|
+
moved = true;
|
|
359
|
+
zoomTo(
|
|
360
|
+
(pinch.scale * Math.hypot(a!.x - b!.x, a!.y - b!.y)) / pinch.distance,
|
|
361
|
+
fromClient((a!.x + b!.x) / 2, (a!.y + b!.y) / 2)
|
|
362
|
+
);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!zoomed.value) return;
|
|
367
|
+
|
|
368
|
+
const dx = now.x - previous.x;
|
|
369
|
+
const dy = now.y - previous.y;
|
|
370
|
+
travel += Math.abs(dx) + Math.abs(dy);
|
|
371
|
+
|
|
372
|
+
if (!moved && travel > 4) {
|
|
373
|
+
moved = true;
|
|
374
|
+
dragging.value = true;
|
|
375
|
+
stage.value?.setPointerCapture(event.pointerId);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (moved) {
|
|
379
|
+
offset.value = clamp(offset.value.x + dx, offset.value.y + dy, scale.value);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function onPointerUp(event: PointerEvent): void {
|
|
384
|
+
pointers.delete(event.pointerId);
|
|
385
|
+
if (pointers.size < 2) pinch = undefined;
|
|
386
|
+
if (!pointers.size) dragging.value = false;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function onImageClick(event: MouseEvent): void {
|
|
390
|
+
if (moved) return;
|
|
391
|
+
|
|
392
|
+
if (zoomed.value) resetZoom();
|
|
393
|
+
else zoomTo(ZOOM_CLICK, fromClient(event.clientX, event.clientY));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function onStageClick(): void {
|
|
397
|
+
if (!moved) open.value = false;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const image = useImage();
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The picture's sources: the dialog's `srcset` while it fits, the ORIGINAL
|
|
404
|
+
* once it is zoomed.
|
|
405
|
+
*
|
|
406
|
+
* A `srcset` candidate is picked for the fitted width, so at 200% it is a
|
|
407
|
+
* blurred enlargement of a screen-sized file. The original is fetched only
|
|
408
|
+
* when a reader zooms. It is the same `<img>` either way, on purpose: changing
|
|
409
|
+
* the sources of one element keeps the current picture on screen until the
|
|
410
|
+
* new file has loaded, where swapping elements would flash an empty box.
|
|
411
|
+
*/
|
|
412
|
+
function viewerSources(variant: Variant) {
|
|
413
|
+
if (variant.plain || zoomed.value) return { src: variant.src };
|
|
414
|
+
|
|
415
|
+
const sources = image.getSizes(variant.src, {
|
|
416
|
+
sizes: PROSE_IMAGE_ZOOM_SIZES
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
return { src: sources.src, srcset: sources.srcset, sizes: sources.sizes };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** One look for the viewer's round controls, which sit on the dark surface in both themes. */
|
|
423
|
+
const viewerButton =
|
|
424
|
+
'inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full bg-white/10 text-white ring-1 ring-white/15 transition-colors hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-none';
|
|
425
|
+
|
|
426
|
+
/** The zoom group's buttons, drawn inside one shared pill rather than each in its own. */
|
|
427
|
+
const zoomButton =
|
|
428
|
+
'inline-flex h-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-white transition-colors hover:bg-white/15 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-none disabled:cursor-default disabled:opacity-40 disabled:hover:bg-transparent';
|
|
429
|
+
|
|
430
|
+
const viewerImage = computed(() => [
|
|
431
|
+
'm-auto h-auto max-h-full w-auto max-w-[min(100%,80rem)] rounded-lg object-contain shadow-2xl ring-1 ring-white/10 select-none',
|
|
432
|
+
dragging.value
|
|
433
|
+
? 'transition-none'
|
|
434
|
+
: 'transition-transform duration-150 ease-out',
|
|
435
|
+
zoomed.value
|
|
436
|
+
? dragging.value
|
|
437
|
+
? 'cursor-grabbing'
|
|
438
|
+
: 'cursor-grab'
|
|
439
|
+
: 'cursor-zoom-in'
|
|
440
|
+
]);
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* THE DIALOG'S FILES HAVE TO EXIST BEFORE ANYONE OPENS IT.
|
|
444
|
+
*
|
|
445
|
+
* A static build learns which image variants to generate from the ones it
|
|
446
|
+
* rendered: `NuxtImg` appends every URL it emits to an `x-nitro-prerender`
|
|
447
|
+
* header, and Nitro's crawler prerenders what that header names. The dialog is
|
|
448
|
+
* not rendered until it opens, so on a generated site its variants were never
|
|
449
|
+
* requested, never written, and the first click found a 404.
|
|
450
|
+
*
|
|
451
|
+
* Asking for the sizes here registers them without rendering anything —
|
|
452
|
+
* `getSizes` runs each URL through the provider, and the provider is what
|
|
453
|
+
* appends the header. The browser downloads none of it: no markup carries these
|
|
454
|
+
* URLs until the dialog mounts.
|
|
455
|
+
*
|
|
456
|
+
* `prose-image.ts` decides WHICH images are on the list — an image with no
|
|
457
|
+
* dialog to open must not have a set of dialog-sized files written for it.
|
|
458
|
+
*
|
|
459
|
+
* Prerender only. On a server that transforms on demand there is nothing to
|
|
460
|
+
* generate ahead of time, and on the client this would be a wasted pass over
|
|
461
|
+
* every image on the page.
|
|
462
|
+
*/
|
|
463
|
+
if (import.meta.server && import.meta.prerender) {
|
|
464
|
+
const image = useImage();
|
|
465
|
+
|
|
466
|
+
for (const src of proseImageZoomPrerenderSources(
|
|
467
|
+
zoomable.value,
|
|
468
|
+
variants.value.map((variant) => variant.src)
|
|
469
|
+
)) {
|
|
470
|
+
image.getSizes(src, { sizes: PROSE_IMAGE_ZOOM_SIZES });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
37
473
|
</script>
|
|
38
474
|
|
|
39
475
|
<template>
|
|
@@ -41,46 +477,157 @@ const zoomable = computed(() => props.zoom !== false && props.zoom !== 'false');
|
|
|
41
477
|
<component
|
|
42
478
|
:is="zoomable ? 'button' : 'span'"
|
|
43
479
|
:type="zoomable ? 'button' : undefined"
|
|
44
|
-
class="block w-full"
|
|
45
|
-
:class="
|
|
46
|
-
|
|
480
|
+
class="relative block w-fit max-w-full"
|
|
481
|
+
:class="
|
|
482
|
+
zoomable
|
|
483
|
+
? 'group ring-offset-background cursor-zoom-in rounded-lg focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none'
|
|
484
|
+
: undefined
|
|
485
|
+
"
|
|
486
|
+
:aria-label="zoomable ? label : undefined"
|
|
47
487
|
@click="zoomable && (open = true)"
|
|
48
488
|
>
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
class="rounded-lg border"
|
|
56
|
-
:class="dark ? 'block dark:hidden' : undefined"
|
|
57
|
-
/>
|
|
58
|
-
<NuxtImg
|
|
59
|
-
v-if="dark"
|
|
60
|
-
:src="dark"
|
|
61
|
-
:alt="alt"
|
|
62
|
-
:width="width"
|
|
63
|
-
:height="height"
|
|
64
|
-
loading="lazy"
|
|
65
|
-
class="hidden rounded-lg border dark:block"
|
|
66
|
-
/>
|
|
67
|
-
</component>
|
|
68
|
-
|
|
69
|
-
<UiDialog v-if="zoomable" v-model:open="open">
|
|
70
|
-
<UiDialogContent class="max-w-[min(96vw,80rem)] p-2">
|
|
71
|
-
<UiDialogTitle class="sr-only">{{ alt }}</UiDialogTitle>
|
|
72
|
-
<NuxtImg
|
|
73
|
-
:src="src"
|
|
489
|
+
<template v-for="variant in variants" :key="variant.src">
|
|
490
|
+
<!-- A vector has no pixels to resample and an animation does not
|
|
491
|
+
survive one: both are served exactly as committed. -->
|
|
492
|
+
<img
|
|
493
|
+
v-if="variant.plain"
|
|
494
|
+
:src="variant.src"
|
|
74
495
|
:alt="alt"
|
|
75
|
-
|
|
76
|
-
:
|
|
496
|
+
:width="width"
|
|
497
|
+
:height="height"
|
|
498
|
+
loading="lazy"
|
|
499
|
+
class="h-auto max-w-full rounded-lg border"
|
|
500
|
+
:class="[variant.theme, backdrop?.class]"
|
|
501
|
+
:style="backdrop?.style"
|
|
77
502
|
/>
|
|
78
503
|
<NuxtImg
|
|
79
|
-
v-
|
|
80
|
-
:src="
|
|
504
|
+
v-else
|
|
505
|
+
:src="variant.src"
|
|
81
506
|
:alt="alt"
|
|
82
|
-
|
|
507
|
+
:width="width"
|
|
508
|
+
:height="height"
|
|
509
|
+
:sizes="pageSizes"
|
|
510
|
+
loading="lazy"
|
|
511
|
+
class="h-auto max-w-full rounded-lg border"
|
|
512
|
+
:class="[variant.theme, backdrop?.class]"
|
|
513
|
+
:style="backdrop?.style"
|
|
83
514
|
/>
|
|
515
|
+
</template>
|
|
516
|
+
|
|
517
|
+
<span
|
|
518
|
+
v-if="zoomable"
|
|
519
|
+
aria-hidden="true"
|
|
520
|
+
class="pointer-events-none absolute top-2 end-2 flex size-7 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm transition-colors group-hover:text-foreground"
|
|
521
|
+
>
|
|
522
|
+
<Icon name="lucide:zoom-in" class="size-4" />
|
|
523
|
+
</span>
|
|
524
|
+
</component>
|
|
525
|
+
|
|
526
|
+
<UiDialog v-if="zoomable" v-model:open="open">
|
|
527
|
+
<!-- The whole viewport, transparent over the overlay: the blur is what
|
|
528
|
+
separates the picture from the page, not a border. -->
|
|
529
|
+
<UiDialogContent
|
|
530
|
+
:show-close-button="false"
|
|
531
|
+
:aria-describedby="undefined"
|
|
532
|
+
class="inset-0 grid h-dvh w-screen max-w-none translate-none grid-rows-[auto_minmax(0,1fr)_auto] gap-0 rounded-none border-0 bg-neutral-950/60 p-0 shadow-none backdrop-blur-md sm:max-w-none"
|
|
533
|
+
@click.self="open = false"
|
|
534
|
+
@keydown="onKeydown"
|
|
535
|
+
>
|
|
536
|
+
<div
|
|
537
|
+
class="flex items-center justify-end gap-2 p-3 sm:p-4"
|
|
538
|
+
@click.self="open = false"
|
|
539
|
+
>
|
|
540
|
+
<div
|
|
541
|
+
class="flex items-center gap-0.5 rounded-full bg-white/10 p-0.5 ring-1 ring-white/15"
|
|
542
|
+
>
|
|
543
|
+
<button
|
|
544
|
+
type="button"
|
|
545
|
+
:aria-label="t('duxt.page.image.zoomOut')"
|
|
546
|
+
:disabled="!zoomed"
|
|
547
|
+
:class="[zoomButton, 'w-8']"
|
|
548
|
+
@click="stepZoom(-1)"
|
|
549
|
+
>
|
|
550
|
+
<Icon name="lucide:minus" class="size-4" />
|
|
551
|
+
</button>
|
|
552
|
+
<button
|
|
553
|
+
type="button"
|
|
554
|
+
:aria-label="t('duxt.page.image.resetZoom')"
|
|
555
|
+
:disabled="!zoomed"
|
|
556
|
+
:class="[
|
|
557
|
+
zoomButton,
|
|
558
|
+
'min-w-14 px-2 text-xs font-medium tabular-nums'
|
|
559
|
+
]"
|
|
560
|
+
@click="resetZoom"
|
|
561
|
+
>
|
|
562
|
+
{{ Math.round(scale * 100) }}%
|
|
563
|
+
</button>
|
|
564
|
+
<button
|
|
565
|
+
type="button"
|
|
566
|
+
:aria-label="t('duxt.page.image.zoomIn')"
|
|
567
|
+
:disabled="scale >= MAX_SCALE"
|
|
568
|
+
:class="[zoomButton, 'w-8']"
|
|
569
|
+
@click="stepZoom(1)"
|
|
570
|
+
>
|
|
571
|
+
<Icon name="lucide:plus" class="size-4" />
|
|
572
|
+
</button>
|
|
573
|
+
</div>
|
|
574
|
+
<a
|
|
575
|
+
:href="original"
|
|
576
|
+
target="_blank"
|
|
577
|
+
rel="noopener"
|
|
578
|
+
:aria-label="t('duxt.page.image.original')"
|
|
579
|
+
:class="viewerButton"
|
|
580
|
+
>
|
|
581
|
+
<Icon name="lucide:external-link" class="size-4" />
|
|
582
|
+
</a>
|
|
583
|
+
<UiDialogClose
|
|
584
|
+
:aria-label="t('duxt.page.image.close')"
|
|
585
|
+
:class="viewerButton"
|
|
586
|
+
>
|
|
587
|
+
<Icon name="lucide:x" class="size-4" />
|
|
588
|
+
</UiDialogClose>
|
|
589
|
+
</div>
|
|
590
|
+
|
|
591
|
+
<!-- `m-auto` centres the picture; `touch-none` hands pinches and
|
|
592
|
+
drags to the handlers instead of scrolling or zooming the page
|
|
593
|
+
underneath. -->
|
|
594
|
+
<div
|
|
595
|
+
ref="stage"
|
|
596
|
+
class="flex min-h-0 touch-none overflow-hidden px-3 sm:px-8"
|
|
597
|
+
@click.self="onStageClick"
|
|
598
|
+
@wheel.prevent="onWheel"
|
|
599
|
+
@pointerdown="onPointerDown"
|
|
600
|
+
@pointermove="onPointerMove"
|
|
601
|
+
@pointerup="onPointerUp"
|
|
602
|
+
@pointercancel="onPointerUp"
|
|
603
|
+
>
|
|
604
|
+
<img
|
|
605
|
+
v-for="variant in variants"
|
|
606
|
+
:key="variant.src"
|
|
607
|
+
v-bind="viewerSources(variant)"
|
|
608
|
+
:alt="alt"
|
|
609
|
+
data-viewer-image
|
|
610
|
+
draggable="false"
|
|
611
|
+
:class="[viewerImage, variant.theme, backdrop?.class]"
|
|
612
|
+
:style="{
|
|
613
|
+
...backdrop?.style,
|
|
614
|
+
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`
|
|
615
|
+
}"
|
|
616
|
+
@click="onImageClick"
|
|
617
|
+
/>
|
|
618
|
+
</div>
|
|
619
|
+
|
|
620
|
+
<div class="px-4 py-3 sm:py-4" @click.self="open = false">
|
|
621
|
+
<UiDialogTitle
|
|
622
|
+
:class="
|
|
623
|
+
alt
|
|
624
|
+
? 'mx-auto max-w-prose text-center text-sm text-balance text-white/80'
|
|
625
|
+
: 'sr-only'
|
|
626
|
+
"
|
|
627
|
+
>
|
|
628
|
+
{{ title }}
|
|
629
|
+
</UiDialogTitle>
|
|
630
|
+
</div>
|
|
84
631
|
</UiDialogContent>
|
|
85
632
|
</UiDialog>
|
|
86
633
|
</span>
|
|
@@ -15,7 +15,7 @@ defineProps<{ version?: string }>();
|
|
|
15
15
|
</script>
|
|
16
16
|
|
|
17
17
|
<template>
|
|
18
|
-
<UiBadge variant="secondary" class="
|
|
18
|
+
<UiBadge variant="secondary" class="ms-2 align-middle font-mono text-[10px]">
|
|
19
19
|
{{ $t('duxt.page.since', { version }) }}
|
|
20
20
|
</UiBadge>
|
|
21
21
|
</template>
|