@mk-kit/ui 0.34.0
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/LICENSE +21 -0
- package/README.md +115 -0
- package/block-editor/README.md +254 -0
- package/fesm2022/mk-kit-ui-block-editor.mjs +2158 -0
- package/fesm2022/mk-kit-ui-block-editor.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-button.mjs +81 -0
- package/fesm2022/mk-kit-ui-button.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-checkbox.mjs +136 -0
- package/fesm2022/mk-kit-ui-checkbox.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-chip.mjs +122 -0
- package/fesm2022/mk-kit-ui-chip.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-context-menu.mjs +144 -0
- package/fesm2022/mk-kit-ui-context-menu.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-core.mjs +1576 -0
- package/fesm2022/mk-kit-ui-core.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-data.mjs +6055 -0
- package/fesm2022/mk-kit-ui-data.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-datetime.mjs +3409 -0
- package/fesm2022/mk-kit-ui-datetime.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-directives.mjs +1779 -0
- package/fesm2022/mk-kit-ui-directives.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-dnd.mjs +1073 -0
- package/fesm2022/mk-kit-ui-dnd.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-feedback.mjs +2426 -0
- package/fesm2022/mk-kit-ui-feedback.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-forms.mjs +9208 -0
- package/fesm2022/mk-kit-ui-forms.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-icon.mjs +470 -0
- package/fesm2022/mk-kit-ui-icon.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-media.mjs +896 -0
- package/fesm2022/mk-kit-ui-media.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-navigation.mjs +2542 -0
- package/fesm2022/mk-kit-ui-navigation.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-rich-text.mjs +565 -0
- package/fesm2022/mk-kit-ui-rich-text.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-table.mjs +1378 -0
- package/fesm2022/mk-kit-ui-table.mjs.map +1 -0
- package/fesm2022/mk-kit-ui.mjs +32 -0
- package/fesm2022/mk-kit-ui.mjs.map +1 -0
- package/package.json +130 -0
- package/schematics/collection.json +10 -0
- package/schematics/ng-add/index.js +113 -0
- package/schematics/ng-add/schema.json +22 -0
- package/schematics/package.json +3 -0
- package/styles/mk-kit.css +750 -0
- package/types/mk-kit-ui-block-editor.d.ts +292 -0
- package/types/mk-kit-ui-button.d.ts +40 -0
- package/types/mk-kit-ui-checkbox.d.ts +62 -0
- package/types/mk-kit-ui-chip.d.ts +59 -0
- package/types/mk-kit-ui-context-menu.d.ts +57 -0
- package/types/mk-kit-ui-core.d.ts +1105 -0
- package/types/mk-kit-ui-data.d.ts +2580 -0
- package/types/mk-kit-ui-datetime.d.ts +1171 -0
- package/types/mk-kit-ui-directives.d.ts +807 -0
- package/types/mk-kit-ui-dnd.d.ts +423 -0
- package/types/mk-kit-ui-feedback.d.ts +1270 -0
- package/types/mk-kit-ui-forms.d.ts +3586 -0
- package/types/mk-kit-ui-icon.d.ts +108 -0
- package/types/mk-kit-ui-media.d.ts +549 -0
- package/types/mk-kit-ui-navigation.d.ts +1169 -0
- package/types/mk-kit-ui-rich-text.d.ts +187 -0
- package/types/mk-kit-ui-table.d.ts +739 -0
- package/types/mk-kit-ui.d.ts +17 -0
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { inject, input, booleanAttribute, output, computed, linkedSignal, ChangeDetectionStrategy, Component, signal, Injectable, numberAttribute, PLATFORM_ID, viewChild, model, effect, untracked, afterNextRender, TemplateRef, Directive, contentChild } from '@angular/core';
|
|
3
|
+
import { MK_I18N, MkOverlayRef, MK_OVERLAY_DATA, MkOverlayService } from '@mk-kit/ui/core';
|
|
4
|
+
import { isPlatformBrowser, NgTemplateOutlet } from '@angular/common';
|
|
5
|
+
import { mkMoveItemInArray, MkDropList, MkDrag } from '@mk-kit/ui/dnd';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Image — an enhanced image block. While the picture loads it shows a skeleton
|
|
9
|
+
* shimmer, and when loading fails it swaps to a localised fallback panel
|
|
10
|
+
* (icon + text, never colour alone). The picture is wrapped in a `<figure>`
|
|
11
|
+
* with an optional `<figcaption>`, and changing `src` restarts the whole load
|
|
12
|
+
* cycle.
|
|
13
|
+
*
|
|
14
|
+
* ```html
|
|
15
|
+
* <mk-image
|
|
16
|
+
* src="/photos/harbour.jpg"
|
|
17
|
+
* alt="Harbour at dawn"
|
|
18
|
+
* aspectRatio="16 / 9"
|
|
19
|
+
* caption="The old harbour, photographed at dawn"
|
|
20
|
+
* (loaded)="onLoaded()"
|
|
21
|
+
* (errored)="onErrored()"
|
|
22
|
+
* />
|
|
23
|
+
*
|
|
24
|
+
* <!-- The hero: eager, high priority, and sized per breakpoint so a phone
|
|
25
|
+
* does not download the desktop file. -->
|
|
26
|
+
* <mk-image
|
|
27
|
+
* src="/photos/hero-1600.jpg"
|
|
28
|
+
* srcset="/photos/hero-800.jpg 800w, /photos/hero-1600.jpg 1600w"
|
|
29
|
+
* sizes="100vw"
|
|
30
|
+
* alt=""
|
|
31
|
+
* aspectRatio="16 / 9"
|
|
32
|
+
* priority
|
|
33
|
+
* />
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* `aspectRatio` is what keeps this from shifting layout: the frame reserves
|
|
37
|
+
* its box before the picture arrives, so nothing below it moves.
|
|
38
|
+
*/
|
|
39
|
+
class MkImage {
|
|
40
|
+
/** Localised strings (override globally via `provideMkI18n`). */
|
|
41
|
+
i18n = inject(MK_I18N);
|
|
42
|
+
/** Image URL. Changing it resets the block to its loading state. */
|
|
43
|
+
src = input.required(/* @ts-ignore */
|
|
44
|
+
...(ngDevMode ? [{ debugName: "src" }] : /* istanbul ignore next */ []));
|
|
45
|
+
/** Alt text, always reflected onto the `<img>`. Keep `''` for decorative images. */
|
|
46
|
+
alt = input('', /* @ts-ignore */
|
|
47
|
+
...(ngDevMode ? [{ debugName: "alt" }] : /* istanbul ignore next */ []));
|
|
48
|
+
/** Any CSS `aspect-ratio` value for the frame, e.g. `'1 / 1'` or `'16 / 9'`. */
|
|
49
|
+
aspectRatio = input('auto', /* @ts-ignore */
|
|
50
|
+
...(ngDevMode ? [{ debugName: "aspectRatio" }] : /* istanbul ignore next */ []));
|
|
51
|
+
/** How the picture fills the frame. */
|
|
52
|
+
fit = input('cover', /* @ts-ignore */
|
|
53
|
+
...(ngDevMode ? [{ debugName: "fit" }] : /* istanbul ignore next */ []));
|
|
54
|
+
/** Corner rounding of the frame. */
|
|
55
|
+
rounded = input('md', /* @ts-ignore */
|
|
56
|
+
...(ngDevMode ? [{ debugName: "rounded" }] : /* istanbul ignore next */ []));
|
|
57
|
+
/** Caption rendered in a `<figcaption>` below the picture. */
|
|
58
|
+
caption = input('', /* @ts-ignore */
|
|
59
|
+
...(ngDevMode ? [{ debugName: "caption" }] : /* istanbul ignore next */ []));
|
|
60
|
+
/** Defer fetching until near the viewport (`loading="lazy"` vs `"eager"`). */
|
|
61
|
+
lazy = input(true, { ...(ngDevMode ? { debugName: "lazy" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
62
|
+
/**
|
|
63
|
+
* Mark this as the page's LCP image: fetched eagerly at high priority and
|
|
64
|
+
* decoded synchronously, and `lazy` is ignored. Use it on exactly ONE image
|
|
65
|
+
* per page — the hero. Marking several is worse than marking none, because
|
|
66
|
+
* they then compete for the same bandwidth the real LCP image needs.
|
|
67
|
+
*
|
|
68
|
+
* This does NOT emit a `<link rel=preload>`; the tag has to be in the HTML
|
|
69
|
+
* the server sends to beat the preload scanner, which a component rendered
|
|
70
|
+
* later cannot guarantee. Pair it with a preload hint for a background or
|
|
71
|
+
* late-rendered hero.
|
|
72
|
+
*/
|
|
73
|
+
priority = input(false, { ...(ngDevMode ? { debugName: "priority" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
74
|
+
/**
|
|
75
|
+
* Responsive candidates, passed through verbatim to the `<img>`. Serving a
|
|
76
|
+
* 2048px file into a 600px slot is one of the most common LCP mistakes, and
|
|
77
|
+
* this is the fix; pair with {@link sizes}.
|
|
78
|
+
*/
|
|
79
|
+
srcset = input('', /* @ts-ignore */
|
|
80
|
+
...(ngDevMode ? [{ debugName: "srcset" }] : /* istanbul ignore next */ []));
|
|
81
|
+
/** `sizes` attribute — how wide the image renders at each breakpoint. */
|
|
82
|
+
sizes = input('', /* @ts-ignore */
|
|
83
|
+
...(ngDevMode ? [{ debugName: "sizes" }] : /* istanbul ignore next */ []));
|
|
84
|
+
/** Emits once the picture has finished loading. */
|
|
85
|
+
loaded = output();
|
|
86
|
+
/** Emits when the picture fails to load and the fallback panel is shown. */
|
|
87
|
+
errored = output();
|
|
88
|
+
/** `loading` attribute: priority always wins over `lazy`. */
|
|
89
|
+
loadingAttr = computed(() => this.priority() ? 'eager' : this.lazy() ? 'lazy' : 'eager', /* @ts-ignore */
|
|
90
|
+
...(ngDevMode ? [{ debugName: "loadingAttr" }] : /* istanbul ignore next */ []));
|
|
91
|
+
/** Load lifecycle; snaps back to `'loading'` whenever `src` changes. */
|
|
92
|
+
state = linkedSignal({ ...(ngDevMode ? { debugName: "state" } : /* istanbul ignore next */ {}), source: this.src,
|
|
93
|
+
computation: () => 'loading' });
|
|
94
|
+
onLoad() {
|
|
95
|
+
this.state.set('loaded');
|
|
96
|
+
this.loaded.emit();
|
|
97
|
+
}
|
|
98
|
+
onError() {
|
|
99
|
+
this.state.set('error');
|
|
100
|
+
this.errored.emit();
|
|
101
|
+
}
|
|
102
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkImage, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
103
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkImage, isStandalone: true, selector: "mk-image", inputs: { src: { classPropertyName: "src", publicName: "src", isSignal: true, isRequired: true, transformFunction: null }, alt: { classPropertyName: "alt", publicName: "alt", isSignal: true, isRequired: false, transformFunction: null }, aspectRatio: { classPropertyName: "aspectRatio", publicName: "aspectRatio", isSignal: true, isRequired: false, transformFunction: null }, fit: { classPropertyName: "fit", publicName: "fit", isSignal: true, isRequired: false, transformFunction: null }, rounded: { classPropertyName: "rounded", publicName: "rounded", isSignal: true, isRequired: false, transformFunction: null }, caption: { classPropertyName: "caption", publicName: "caption", isSignal: true, isRequired: false, transformFunction: null }, lazy: { classPropertyName: "lazy", publicName: "lazy", isSignal: true, isRequired: false, transformFunction: null }, priority: { classPropertyName: "priority", publicName: "priority", isSignal: true, isRequired: false, transformFunction: null }, srcset: { classPropertyName: "srcset", publicName: "srcset", isSignal: true, isRequired: false, transformFunction: null }, sizes: { classPropertyName: "sizes", publicName: "sizes", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { loaded: "loaded", errored: "errored" }, host: { properties: { "attr.data-fit": "fit()", "attr.data-rounded": "rounded()", "attr.data-state": "state()" }, classAttribute: "mk-image" }, exportAs: ["mkImage"], ngImport: i0, template: "<figure class=\"mk-image__figure\">\n <div class=\"mk-image__frame\" [style.aspect-ratio]=\"aspectRatio()\">\n @if (state() !== 'error') {\n <img\n class=\"mk-image__img\"\n [src]=\"src()\"\n [alt]=\"alt()\"\n [attr.srcset]=\"srcset() || null\"\n [attr.sizes]=\"sizes() || null\"\n [attr.loading]=\"loadingAttr()\"\n [attr.fetchpriority]=\"priority() ? 'high' : null\"\n [attr.decoding]=\"priority() ? 'sync' : 'async'\"\n (load)=\"onLoad()\"\n (error)=\"onError()\"\n />\n @if (state() === 'loading') {\n <span class=\"mk-image__skeleton\" aria-hidden=\"true\"></span>\n }\n } @else {\n <div class=\"mk-image__fallback\">\n <svg\n class=\"mk-image__fallback-icon\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.5\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"2\" />\n <circle cx=\"9\" cy=\"10\" r=\"1.5\" />\n <path d=\"m5 17 4-4 3 3 3.5-3.5L20 17\" />\n <path d=\"m4 3 16 18\" />\n </svg>\n <span class=\"mk-image__fallback-text\">{{ i18n.imageFailed }}</span>\n </div>\n }\n </div>\n @if (caption()) {\n <figcaption class=\"mk-image__caption\">{{ caption() }}</figcaption>\n }\n</figure>\n", styles: ["@charset \"UTF-8\";:host{--_radius: var(--mk-radius-md);display:block;min-width:0;font-family:var(--mk-font-sans)}:host([data-rounded=none]){--_radius: var(--mk-radius-none)}:host([data-rounded=lg]){--_radius: var(--mk-radius-lg)}:host([data-rounded=circle]){--_radius: var(--mk-radius-circle)}.mk-image__figure{display:flex;flex-direction:column;gap:var(--mk-space-2);margin:0;min-width:0}.mk-image__frame{position:relative;width:100%;overflow:hidden;border-radius:var(--_radius);background-color:var(--mk-surface-2)}.mk-image__img{display:block;width:100%;height:100%;object-fit:cover}:host([data-fit=contain]) .mk-image__img{object-fit:contain}:host([data-state=loading]) .mk-image__img{opacity:0}.mk-image__skeleton{position:absolute;inset:0;overflow:hidden;background-color:var(--mk-skeleton-base)}.mk-image__skeleton:after{content:\"\";position:absolute;inset:0;width:200%;background-image:linear-gradient(90deg,var(--mk-skeleton-base) 0%,var(--mk-skeleton-shine) 50%,var(--mk-skeleton-base) 100%);animation:mk-image-shimmer 1.4s ease-in-out infinite}@keyframes mk-image-shimmer{0%{transform:translate(-50%)}to{transform:translate(50%)}}.mk-image__fallback{box-sizing:border-box;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--mk-space-2);width:100%;height:100%;min-height:var(--mk-control-height-lg);padding:var(--mk-space-4);background-color:var(--mk-surface-2);border:var(--mk-border-width) dashed var(--mk-border);border-radius:var(--_radius);color:var(--mk-text-muted);text-align:center}.mk-image__fallback-icon{flex:none;width:var(--mk-space-6);height:var(--mk-space-6)}.mk-image__fallback-text{font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-snug)}.mk-image__caption{font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-snug);color:var(--mk-text-muted)}@media(prefers-reduced-motion:reduce){.mk-image__skeleton:after{content:none;animation:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
104
|
+
}
|
|
105
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkImage, decorators: [{
|
|
106
|
+
type: Component,
|
|
107
|
+
args: [{ selector: 'mk-image', exportAs: 'mkImage', changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
108
|
+
class: 'mk-image',
|
|
109
|
+
'[attr.data-fit]': 'fit()',
|
|
110
|
+
'[attr.data-rounded]': 'rounded()',
|
|
111
|
+
'[attr.data-state]': 'state()',
|
|
112
|
+
}, template: "<figure class=\"mk-image__figure\">\n <div class=\"mk-image__frame\" [style.aspect-ratio]=\"aspectRatio()\">\n @if (state() !== 'error') {\n <img\n class=\"mk-image__img\"\n [src]=\"src()\"\n [alt]=\"alt()\"\n [attr.srcset]=\"srcset() || null\"\n [attr.sizes]=\"sizes() || null\"\n [attr.loading]=\"loadingAttr()\"\n [attr.fetchpriority]=\"priority() ? 'high' : null\"\n [attr.decoding]=\"priority() ? 'sync' : 'async'\"\n (load)=\"onLoad()\"\n (error)=\"onError()\"\n />\n @if (state() === 'loading') {\n <span class=\"mk-image__skeleton\" aria-hidden=\"true\"></span>\n }\n } @else {\n <div class=\"mk-image__fallback\">\n <svg\n class=\"mk-image__fallback-icon\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.5\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"2\" />\n <circle cx=\"9\" cy=\"10\" r=\"1.5\" />\n <path d=\"m5 17 4-4 3 3 3.5-3.5L20 17\" />\n <path d=\"m4 3 16 18\" />\n </svg>\n <span class=\"mk-image__fallback-text\">{{ i18n.imageFailed }}</span>\n </div>\n }\n </div>\n @if (caption()) {\n <figcaption class=\"mk-image__caption\">{{ caption() }}</figcaption>\n }\n</figure>\n", styles: ["@charset \"UTF-8\";:host{--_radius: var(--mk-radius-md);display:block;min-width:0;font-family:var(--mk-font-sans)}:host([data-rounded=none]){--_radius: var(--mk-radius-none)}:host([data-rounded=lg]){--_radius: var(--mk-radius-lg)}:host([data-rounded=circle]){--_radius: var(--mk-radius-circle)}.mk-image__figure{display:flex;flex-direction:column;gap:var(--mk-space-2);margin:0;min-width:0}.mk-image__frame{position:relative;width:100%;overflow:hidden;border-radius:var(--_radius);background-color:var(--mk-surface-2)}.mk-image__img{display:block;width:100%;height:100%;object-fit:cover}:host([data-fit=contain]) .mk-image__img{object-fit:contain}:host([data-state=loading]) .mk-image__img{opacity:0}.mk-image__skeleton{position:absolute;inset:0;overflow:hidden;background-color:var(--mk-skeleton-base)}.mk-image__skeleton:after{content:\"\";position:absolute;inset:0;width:200%;background-image:linear-gradient(90deg,var(--mk-skeleton-base) 0%,var(--mk-skeleton-shine) 50%,var(--mk-skeleton-base) 100%);animation:mk-image-shimmer 1.4s ease-in-out infinite}@keyframes mk-image-shimmer{0%{transform:translate(-50%)}to{transform:translate(50%)}}.mk-image__fallback{box-sizing:border-box;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--mk-space-2);width:100%;height:100%;min-height:var(--mk-control-height-lg);padding:var(--mk-space-4);background-color:var(--mk-surface-2);border:var(--mk-border-width) dashed var(--mk-border);border-radius:var(--_radius);color:var(--mk-text-muted);text-align:center}.mk-image__fallback-icon{flex:none;width:var(--mk-space-6);height:var(--mk-space-6)}.mk-image__fallback-text{font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-snug)}.mk-image__caption{font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-snug);color:var(--mk-text-muted)}@media(prefers-reduced-motion:reduce){.mk-image__skeleton:after{content:none;animation:none}}\n"] }]
|
|
113
|
+
}], propDecorators: { src: [{ type: i0.Input, args: [{ isSignal: true, alias: "src", required: true }] }], alt: [{ type: i0.Input, args: [{ isSignal: true, alias: "alt", required: false }] }], aspectRatio: [{ type: i0.Input, args: [{ isSignal: true, alias: "aspectRatio", required: false }] }], fit: [{ type: i0.Input, args: [{ isSignal: true, alias: "fit", required: false }] }], rounded: [{ type: i0.Input, args: [{ isSignal: true, alias: "rounded", required: false }] }], caption: [{ type: i0.Input, args: [{ isSignal: true, alias: "caption", required: false }] }], lazy: [{ type: i0.Input, args: [{ isSignal: true, alias: "lazy", required: false }] }], priority: [{ type: i0.Input, args: [{ isSignal: true, alias: "priority", required: false }] }], srcset: [{ type: i0.Input, args: [{ isSignal: true, alias: "srcset", required: false }] }], sizes: [{ type: i0.Input, args: [{ isSignal: true, alias: "sizes", required: false }] }], loaded: [{ type: i0.Output, args: ["loaded"] }], errored: [{ type: i0.Output, args: ["errored"] }] } });
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Internal overlay component backing {@link MkLightboxService}. Renders the
|
|
117
|
+
* current image near-fullscreen over the overlay scrim with a caption bar,
|
|
118
|
+
* an `imageOf` counter, a close button and looping previous/next navigation.
|
|
119
|
+
*
|
|
120
|
+
* Keyboard: `ArrowLeft`/`ArrowRight` navigate, `Home`/`End` jump to the first
|
|
121
|
+
* or last image. `Escape` and backdrop clicks are handled by the overlay
|
|
122
|
+
* itself (`closeOnEscape` / `closeOnBackdropClick`), so they are deliberately
|
|
123
|
+
* not re-handled here.
|
|
124
|
+
*
|
|
125
|
+
* Open it via the service — not by placing `<mk-lightbox>` in a template:
|
|
126
|
+
*
|
|
127
|
+
* ```ts
|
|
128
|
+
* inject(MkLightboxService).open(items, 2);
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
class MkLightbox {
|
|
132
|
+
ref = inject(MkOverlayRef);
|
|
133
|
+
data = inject(MK_OVERLAY_DATA);
|
|
134
|
+
/** Localised strings (override globally via `provideMkI18n`). */
|
|
135
|
+
i18n = inject(MK_I18N);
|
|
136
|
+
/** The images being paged through. */
|
|
137
|
+
items = this.data?.items ?? [];
|
|
138
|
+
/** Total number of images. */
|
|
139
|
+
total = this.items.length;
|
|
140
|
+
/** Index of the currently displayed image. */
|
|
141
|
+
index = signal(Math.min(Math.max(this.data?.startIndex ?? 0, 0), Math.max(this.total - 1, 0)), /* @ts-ignore */
|
|
142
|
+
...(ngDevMode ? [{ debugName: "index" }] : /* istanbul ignore next */ []));
|
|
143
|
+
/** The currently displayed item. */
|
|
144
|
+
current = computed(() => this.items[this.index()], /* @ts-ignore */
|
|
145
|
+
...(ngDevMode ? [{ debugName: "current" }] : /* istanbul ignore next */ []));
|
|
146
|
+
/** Localised "Image i of n" counter text. */
|
|
147
|
+
counter = computed(() => this.i18n.imageOf(this.index() + 1, this.total), /* @ts-ignore */
|
|
148
|
+
...(ngDevMode ? [{ debugName: "counter" }] : /* istanbul ignore next */ []));
|
|
149
|
+
/** `true` while the current image is still loading. */
|
|
150
|
+
loading = signal(true, /* @ts-ignore */
|
|
151
|
+
...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
|
|
152
|
+
/** `true` when the current image failed to load. */
|
|
153
|
+
failed = signal(false, /* @ts-ignore */
|
|
154
|
+
...(ngDevMode ? [{ debugName: "failed" }] : /* istanbul ignore next */ []));
|
|
155
|
+
/** Show the previous image, wrapping from the first to the last. */
|
|
156
|
+
previous() {
|
|
157
|
+
this.goTo((this.index() - 1 + this.total) % this.total);
|
|
158
|
+
}
|
|
159
|
+
/** Show the next image, wrapping from the last back to the first. */
|
|
160
|
+
next() {
|
|
161
|
+
this.goTo((this.index() + 1) % this.total);
|
|
162
|
+
}
|
|
163
|
+
/** Jump to `index`, resetting the loading/error state. */
|
|
164
|
+
goTo(index) {
|
|
165
|
+
if (this.total === 0 || index === this.index())
|
|
166
|
+
return;
|
|
167
|
+
this.index.set(index);
|
|
168
|
+
this.loading.set(true);
|
|
169
|
+
this.failed.set(false);
|
|
170
|
+
}
|
|
171
|
+
/** Close the surrounding overlay. */
|
|
172
|
+
close() {
|
|
173
|
+
this.ref.close();
|
|
174
|
+
}
|
|
175
|
+
/** Current image finished loading — hide the spinner. */
|
|
176
|
+
onLoad() {
|
|
177
|
+
this.loading.set(false);
|
|
178
|
+
}
|
|
179
|
+
/** Current image failed — show the localised failure message instead. */
|
|
180
|
+
onError() {
|
|
181
|
+
this.loading.set(false);
|
|
182
|
+
this.failed.set(true);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Host keydown handler. Escape is intentionally not handled here — the
|
|
186
|
+
* overlay's `closeOnEscape` already closes the lightbox.
|
|
187
|
+
*/
|
|
188
|
+
onKeydown(event) {
|
|
189
|
+
const key = event.key;
|
|
190
|
+
switch (key) {
|
|
191
|
+
case 'ArrowLeft':
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
this.previous();
|
|
194
|
+
break;
|
|
195
|
+
case 'ArrowRight':
|
|
196
|
+
event.preventDefault();
|
|
197
|
+
this.next();
|
|
198
|
+
break;
|
|
199
|
+
case 'Home':
|
|
200
|
+
event.preventDefault();
|
|
201
|
+
this.goTo(0);
|
|
202
|
+
break;
|
|
203
|
+
case 'End':
|
|
204
|
+
event.preventDefault();
|
|
205
|
+
this.goTo(this.total - 1);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkLightbox, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
210
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkLightbox, isStandalone: true, selector: "mk-lightbox", host: { listeners: { "keydown": "onKeydown($event)" }, properties: { "class.mk-lightbox": "true" } }, ngImport: i0, template: "@if (current(); as item) {\n <figure class=\"mk-lightbox__figure\">\n <div class=\"mk-lightbox__stage\">\n @if (failed()) {\n <p class=\"mk-lightbox__error\" role=\"alert\">{{ i18n.imageFailed }}</p>\n } @else {\n @if (loading()) {\n <span class=\"mk-lightbox__spinner\" aria-hidden=\"true\"></span>\n }\n <img\n class=\"mk-lightbox__image\"\n [class.mk-lightbox__image--loading]=\"loading()\"\n [src]=\"item.src\"\n [alt]=\"item.alt ?? ''\"\n (load)=\"onLoad()\"\n (error)=\"onError()\"\n />\n }\n </div>\n @if (item.caption) {\n <figcaption class=\"mk-lightbox__caption\">{{ item.caption }}</figcaption>\n }\n <p class=\"mk-lightbox__counter\" aria-live=\"polite\">{{ counter() }}</p>\n </figure>\n}\n\n<button\n type=\"button\"\n class=\"mk-lightbox__button mk-lightbox__close\"\n [attr.aria-label]=\"i18n.close\"\n (click)=\"close()\"\n>\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M6 6l12 12M18 6L6 18\" />\n </svg>\n</button>\n\n@if (total > 1) {\n <button\n type=\"button\"\n class=\"mk-lightbox__button mk-lightbox__nav mk-lightbox__nav--prev\"\n [attr.aria-label]=\"i18n.previousImage\"\n (click)=\"previous()\"\n >\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M15 5l-7 7 7 7\" />\n </svg>\n </button>\n <button\n type=\"button\"\n class=\"mk-lightbox__button mk-lightbox__nav mk-lightbox__nav--next\"\n [attr.aria-label]=\"i18n.nextImage\"\n (click)=\"next()\"\n >\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M9 5l7 7-7 7\" />\n </svg>\n </button>\n}\n", styles: [":host{--_chrome-bg: color-mix(in srgb, var(--mk-surface-inverse) 60%, transparent);--_chrome-bg-hover: color-mix( in srgb, var(--mk-surface-inverse) 80%, transparent );--_chrome-fg: var(--mk-text-inverse);display:flex;align-items:center;justify-content:center;width:100vw;height:100dvh;font-family:var(--mk-font-sans)}.mk-lightbox__figure{display:flex;flex-direction:column;align-items:center;gap:var(--mk-space-3);margin:0;max-width:90vw}.mk-lightbox__stage{position:relative;display:grid;place-items:center;min-width:4rem;min-height:4rem}.mk-lightbox__image{display:block;max-width:90vw;max-height:85vh;object-fit:contain;border-radius:var(--mk-radius-md);box-shadow:var(--mk-shadow-xl);transition:opacity var(--mk-duration-normal) var(--mk-ease-out)}.mk-lightbox__image--loading{opacity:0}.mk-lightbox__spinner{position:absolute;inset:0;margin:auto;width:2rem;height:2rem;border:3px solid color-mix(in srgb,var(--mk-surface-inverse) 45%,transparent);border-top-color:var(--_chrome-fg);border-radius:var(--mk-radius-circle);animation:mk-lightbox-spin var(--mk-duration-slow) linear infinite}.mk-lightbox__error{margin:0;padding:var(--mk-space-3) var(--mk-space-5);border-radius:var(--mk-radius-lg);background:var(--_chrome-bg);color:var(--_chrome-fg);font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-snug)}.mk-lightbox__caption{max-width:90vw;padding:var(--mk-space-2) var(--mk-space-4);border-radius:var(--mk-radius-pill);background:var(--_chrome-bg);color:var(--_chrome-fg);font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-snug);text-align:center}.mk-lightbox__counter{margin:0;padding:var(--mk-space-1) var(--mk-space-3);border-radius:var(--mk-radius-pill);background:var(--_chrome-bg);color:var(--_chrome-fg);font-size:var(--mk-font-size-xs);font-variant-numeric:tabular-nums}.mk-lightbox__button{position:absolute;display:grid;place-items:center;width:2.75rem;height:2.75rem;padding:0;border:0;border-radius:var(--mk-radius-circle);background:var(--_chrome-bg);color:var(--_chrome-fg);cursor:pointer;transition:background-color var(--mk-duration-fast) var(--mk-ease-standard),scale var(--mk-duration-fast) var(--mk-ease-standard)}.mk-lightbox__button:hover{background:var(--_chrome-bg-hover)}.mk-lightbox__button:active{scale:.94}.mk-lightbox__button:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-lightbox__button svg{width:1.25rem;height:1.25rem}.mk-lightbox__close{top:var(--mk-space-4);right:var(--mk-space-4)}.mk-lightbox__nav{top:50%;translate:0 -50%}.mk-lightbox__nav--prev{left:var(--mk-space-4)}.mk-lightbox__nav--next{right:var(--mk-space-4)}@keyframes mk-lightbox-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.mk-lightbox__image,.mk-lightbox__button{transition:none}.mk-lightbox__spinner{animation:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
211
|
+
}
|
|
212
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkLightbox, decorators: [{
|
|
213
|
+
type: Component,
|
|
214
|
+
args: [{ selector: 'mk-lightbox', changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
215
|
+
// Bound (not static) so it merges with the overlay's own panel classes —
|
|
216
|
+
// a static host `class` would overwrite `mk-overlay-panel` on the host.
|
|
217
|
+
'[class.mk-lightbox]': 'true',
|
|
218
|
+
'(keydown)': 'onKeydown($event)',
|
|
219
|
+
}, template: "@if (current(); as item) {\n <figure class=\"mk-lightbox__figure\">\n <div class=\"mk-lightbox__stage\">\n @if (failed()) {\n <p class=\"mk-lightbox__error\" role=\"alert\">{{ i18n.imageFailed }}</p>\n } @else {\n @if (loading()) {\n <span class=\"mk-lightbox__spinner\" aria-hidden=\"true\"></span>\n }\n <img\n class=\"mk-lightbox__image\"\n [class.mk-lightbox__image--loading]=\"loading()\"\n [src]=\"item.src\"\n [alt]=\"item.alt ?? ''\"\n (load)=\"onLoad()\"\n (error)=\"onError()\"\n />\n }\n </div>\n @if (item.caption) {\n <figcaption class=\"mk-lightbox__caption\">{{ item.caption }}</figcaption>\n }\n <p class=\"mk-lightbox__counter\" aria-live=\"polite\">{{ counter() }}</p>\n </figure>\n}\n\n<button\n type=\"button\"\n class=\"mk-lightbox__button mk-lightbox__close\"\n [attr.aria-label]=\"i18n.close\"\n (click)=\"close()\"\n>\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M6 6l12 12M18 6L6 18\" />\n </svg>\n</button>\n\n@if (total > 1) {\n <button\n type=\"button\"\n class=\"mk-lightbox__button mk-lightbox__nav mk-lightbox__nav--prev\"\n [attr.aria-label]=\"i18n.previousImage\"\n (click)=\"previous()\"\n >\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M15 5l-7 7 7 7\" />\n </svg>\n </button>\n <button\n type=\"button\"\n class=\"mk-lightbox__button mk-lightbox__nav mk-lightbox__nav--next\"\n [attr.aria-label]=\"i18n.nextImage\"\n (click)=\"next()\"\n >\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M9 5l7 7-7 7\" />\n </svg>\n </button>\n}\n", styles: [":host{--_chrome-bg: color-mix(in srgb, var(--mk-surface-inverse) 60%, transparent);--_chrome-bg-hover: color-mix( in srgb, var(--mk-surface-inverse) 80%, transparent );--_chrome-fg: var(--mk-text-inverse);display:flex;align-items:center;justify-content:center;width:100vw;height:100dvh;font-family:var(--mk-font-sans)}.mk-lightbox__figure{display:flex;flex-direction:column;align-items:center;gap:var(--mk-space-3);margin:0;max-width:90vw}.mk-lightbox__stage{position:relative;display:grid;place-items:center;min-width:4rem;min-height:4rem}.mk-lightbox__image{display:block;max-width:90vw;max-height:85vh;object-fit:contain;border-radius:var(--mk-radius-md);box-shadow:var(--mk-shadow-xl);transition:opacity var(--mk-duration-normal) var(--mk-ease-out)}.mk-lightbox__image--loading{opacity:0}.mk-lightbox__spinner{position:absolute;inset:0;margin:auto;width:2rem;height:2rem;border:3px solid color-mix(in srgb,var(--mk-surface-inverse) 45%,transparent);border-top-color:var(--_chrome-fg);border-radius:var(--mk-radius-circle);animation:mk-lightbox-spin var(--mk-duration-slow) linear infinite}.mk-lightbox__error{margin:0;padding:var(--mk-space-3) var(--mk-space-5);border-radius:var(--mk-radius-lg);background:var(--_chrome-bg);color:var(--_chrome-fg);font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-snug)}.mk-lightbox__caption{max-width:90vw;padding:var(--mk-space-2) var(--mk-space-4);border-radius:var(--mk-radius-pill);background:var(--_chrome-bg);color:var(--_chrome-fg);font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-snug);text-align:center}.mk-lightbox__counter{margin:0;padding:var(--mk-space-1) var(--mk-space-3);border-radius:var(--mk-radius-pill);background:var(--_chrome-bg);color:var(--_chrome-fg);font-size:var(--mk-font-size-xs);font-variant-numeric:tabular-nums}.mk-lightbox__button{position:absolute;display:grid;place-items:center;width:2.75rem;height:2.75rem;padding:0;border:0;border-radius:var(--mk-radius-circle);background:var(--_chrome-bg);color:var(--_chrome-fg);cursor:pointer;transition:background-color var(--mk-duration-fast) var(--mk-ease-standard),scale var(--mk-duration-fast) var(--mk-ease-standard)}.mk-lightbox__button:hover{background:var(--_chrome-bg-hover)}.mk-lightbox__button:active{scale:.94}.mk-lightbox__button:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-lightbox__button svg{width:1.25rem;height:1.25rem}.mk-lightbox__close{top:var(--mk-space-4);right:var(--mk-space-4)}.mk-lightbox__nav{top:50%;translate:0 -50%}.mk-lightbox__nav--prev{left:var(--mk-space-4)}.mk-lightbox__nav--next{right:var(--mk-space-4)}@keyframes mk-lightbox-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.mk-lightbox__image,.mk-lightbox__button{transition:none}.mk-lightbox__spinner{animation:none}}\n"] }]
|
|
220
|
+
}] });
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Fullscreen image lightbox — opens a set of images in a modal overlay with
|
|
224
|
+
* looping previous/next navigation, captions, a counter, keyboard support
|
|
225
|
+
* (Arrow keys, Home/End, Escape) and focus trapping, all powered by
|
|
226
|
+
* {@link MkOverlayService}.
|
|
227
|
+
*
|
|
228
|
+
* ```ts
|
|
229
|
+
* private readonly lightbox = inject(MkLightboxService);
|
|
230
|
+
*
|
|
231
|
+
* viewPhoto(index: number): void {
|
|
232
|
+
* this.lightbox.open(
|
|
233
|
+
* [
|
|
234
|
+
* { src: '/photos/1.jpg', alt: 'Harbour at dawn', caption: 'Dawn' },
|
|
235
|
+
* { src: '/photos/2.jpg', alt: 'Old town square' },
|
|
236
|
+
* ],
|
|
237
|
+
* index,
|
|
238
|
+
* );
|
|
239
|
+
* }
|
|
240
|
+
* ```
|
|
241
|
+
*/
|
|
242
|
+
class MkLightboxService {
|
|
243
|
+
overlay = inject(MkOverlayService);
|
|
244
|
+
i18n = inject(MK_I18N);
|
|
245
|
+
/**
|
|
246
|
+
* Open the lightbox over the page. Does nothing when `items` is empty.
|
|
247
|
+
* `startIndex` is clamped into `[0, items.length - 1]`.
|
|
248
|
+
*/
|
|
249
|
+
open(items, startIndex = 0) {
|
|
250
|
+
if (items.length === 0)
|
|
251
|
+
return;
|
|
252
|
+
const start = Math.min(Math.max(Math.trunc(startIndex), 0), items.length - 1);
|
|
253
|
+
this.overlay.open(MkLightbox, {
|
|
254
|
+
data: { items, startIndex: start },
|
|
255
|
+
hasBackdrop: true,
|
|
256
|
+
closeOnBackdropClick: true,
|
|
257
|
+
closeOnEscape: true,
|
|
258
|
+
trapFocus: true,
|
|
259
|
+
role: 'dialog',
|
|
260
|
+
ariaLabel: this.i18n.imageOf(start + 1, items.length),
|
|
261
|
+
panelClass: 'mk-lightbox-panel',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkLightboxService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
265
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkLightboxService, providedIn: 'root' });
|
|
266
|
+
}
|
|
267
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkLightboxService, decorators: [{
|
|
268
|
+
type: Injectable,
|
|
269
|
+
args: [{ providedIn: 'root' }]
|
|
270
|
+
}] });
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Image gallery — a wall of clickable picture tiles built on {@link MkImage}.
|
|
274
|
+
* Choose a uniform `grid`, a natural-ratio `masonry` flow, or a snap-scrolling
|
|
275
|
+
* horizontal `strip`. Clicking a tile opens the shared {@link MkLightboxService}
|
|
276
|
+
* at that picture (full-size `src`, not the thumb) and always emits
|
|
277
|
+
* `itemClick`. With `max` set, only the first `max` tiles render and the last
|
|
278
|
+
* one carries a "+N" overflow scrim that opens the rest in the lightbox.
|
|
279
|
+
*
|
|
280
|
+
* ```html
|
|
281
|
+
* <mk-image-gallery
|
|
282
|
+
* [items]="[
|
|
283
|
+
* { src: '/photos/1.jpg', thumb: '/thumbs/1.jpg', alt: 'Harbour at dawn' },
|
|
284
|
+
* { src: '/photos/2.jpg', alt: 'Old town square', caption: 'Kraków' },
|
|
285
|
+
* ]"
|
|
286
|
+
* [columns]="4"
|
|
287
|
+
* [max]="8"
|
|
288
|
+
* aspectRatio="4 / 3"
|
|
289
|
+
* (itemClick)="onItemClick($event)"
|
|
290
|
+
* />
|
|
291
|
+
* ```
|
|
292
|
+
*/
|
|
293
|
+
class MkImageGallery {
|
|
294
|
+
lightboxService = inject(MkLightboxService);
|
|
295
|
+
/** Localised strings (override globally via `provideMkI18n`). */
|
|
296
|
+
i18n = inject(MK_I18N);
|
|
297
|
+
/** Pictures to show. */
|
|
298
|
+
items = input.required(/* @ts-ignore */
|
|
299
|
+
...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
|
|
300
|
+
/** Tile arrangement. */
|
|
301
|
+
layout = input('grid', /* @ts-ignore */
|
|
302
|
+
...(ngDevMode ? [{ debugName: "layout" }] : /* istanbul ignore next */ []));
|
|
303
|
+
/** Grid/masonry column count; also sets the visible tile count of a strip. */
|
|
304
|
+
columns = input(3, { ...(ngDevMode ? { debugName: "columns" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
305
|
+
/** When set, show only the first `max` tiles plus a "+N" overflow tile. */
|
|
306
|
+
max = input(null, /* @ts-ignore */
|
|
307
|
+
...(ngDevMode ? [{ debugName: "max" }] : /* istanbul ignore next */ []));
|
|
308
|
+
/** Open the shared lightbox on tile click. */
|
|
309
|
+
lightbox = input(true, { ...(ngDevMode ? { debugName: "lightbox" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
310
|
+
/** CSS aspect-ratio of grid/strip tiles (masonry keeps natural ratios). */
|
|
311
|
+
aspectRatio = input('1 / 1', /* @ts-ignore */
|
|
312
|
+
...(ngDevMode ? [{ debugName: "aspectRatio" }] : /* istanbul ignore next */ []));
|
|
313
|
+
/** Corner rounding of the tiles. */
|
|
314
|
+
rounded = input('md', /* @ts-ignore */
|
|
315
|
+
...(ngDevMode ? [{ debugName: "rounded" }] : /* istanbul ignore next */ []));
|
|
316
|
+
/** Emits on every tile click (also when the lightbox opens). */
|
|
317
|
+
itemClick = output();
|
|
318
|
+
/** Tiles actually rendered — the first `max` when `max` is set. */
|
|
319
|
+
visibleItems = computed(() => {
|
|
320
|
+
const items = this.items();
|
|
321
|
+
const max = this.max();
|
|
322
|
+
return max != null && items.length > max
|
|
323
|
+
? items.slice(0, Math.max(0, max))
|
|
324
|
+
: items;
|
|
325
|
+
}, /* @ts-ignore */
|
|
326
|
+
...(ngDevMode ? [{ debugName: "visibleItems" }] : /* istanbul ignore next */ []));
|
|
327
|
+
/** How many pictures the trailing "+N" tile stands for (0 = no overflow). */
|
|
328
|
+
overflowCount = computed(() => this.items().length - this.visibleItems().length, /* @ts-ignore */
|
|
329
|
+
...(ngDevMode ? [{ debugName: "overflowCount" }] : /* istanbul ignore next */ []));
|
|
330
|
+
/** Frame ratio forwarded to the tiles (`'auto'` in masonry). */
|
|
331
|
+
tileAspectRatio = computed(() => this.layout() === 'masonry' ? 'auto' : this.aspectRatio(), /* @ts-ignore */
|
|
332
|
+
...(ngDevMode ? [{ debugName: "tileAspectRatio" }] : /* istanbul ignore next */ []));
|
|
333
|
+
/**
|
|
334
|
+
* Accessible label of tile `index`: `viewImage(alt)` when the picture has
|
|
335
|
+
* alt text, else positional `imageOf(...)`; the overflow tile appends "+N".
|
|
336
|
+
*/
|
|
337
|
+
tileLabel(index) {
|
|
338
|
+
const items = this.items();
|
|
339
|
+
const alt = items[index]?.alt;
|
|
340
|
+
const base = alt
|
|
341
|
+
? this.i18n.viewImage(alt)
|
|
342
|
+
: this.i18n.imageOf(index + 1, items.length);
|
|
343
|
+
const overflow = this.overflowCount();
|
|
344
|
+
const isOverflowTile = overflow > 0 && index === this.visibleItems().length - 1;
|
|
345
|
+
return isOverflowTile ? `${base} (+${overflow})` : base;
|
|
346
|
+
}
|
|
347
|
+
onTileClick(index) {
|
|
348
|
+
const items = this.items();
|
|
349
|
+
if (this.lightbox()) {
|
|
350
|
+
const lightboxItems = items.map(({ src, alt, caption }) => ({ src, alt, caption }));
|
|
351
|
+
this.lightboxService.open(lightboxItems, index);
|
|
352
|
+
}
|
|
353
|
+
this.itemClick.emit({ item: items[index], index });
|
|
354
|
+
}
|
|
355
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkImageGallery, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
356
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkImageGallery, isStandalone: true, selector: "mk-image-gallery", inputs: { items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: true, transformFunction: null }, layout: { classPropertyName: "layout", publicName: "layout", isSignal: true, isRequired: false, transformFunction: null }, columns: { classPropertyName: "columns", publicName: "columns", isSignal: true, isRequired: false, transformFunction: null }, max: { classPropertyName: "max", publicName: "max", isSignal: true, isRequired: false, transformFunction: null }, lightbox: { classPropertyName: "lightbox", publicName: "lightbox", isSignal: true, isRequired: false, transformFunction: null }, aspectRatio: { classPropertyName: "aspectRatio", publicName: "aspectRatio", isSignal: true, isRequired: false, transformFunction: null }, rounded: { classPropertyName: "rounded", publicName: "rounded", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { itemClick: "itemClick" }, host: { properties: { "attr.data-layout": "layout()", "attr.data-rounded": "rounded()", "style.--_cols": "columns()" }, classAttribute: "mk-image-gallery" }, exportAs: ["mkImageGallery"], ngImport: i0, template: "@for (item of visibleItems(); track $index; let i = $index; let last = $last) {\n <button\n type=\"button\"\n class=\"mk-gallery__tile\"\n [attr.aria-label]=\"tileLabel(i)\"\n (click)=\"onTileClick(i)\"\n >\n <mk-image\n [src]=\"item.thumb ?? item.src\"\n [aspectRatio]=\"tileAspectRatio()\"\n [rounded]=\"rounded()\"\n />\n @if (last && overflowCount() > 0) {\n <span class=\"mk-gallery__more\" aria-hidden=\"true\"\n >+{{ overflowCount() }}</span\n >\n }\n </button>\n}\n", styles: [":host{--_cols: 3;--_gap: var(--mk-space-2);--_radius: var(--mk-radius-md);display:grid;grid-template-columns:repeat(var(--_cols),1fr);gap:var(--_gap);min-width:0;font-family:var(--mk-font-sans)}:host([data-rounded=none]){--_radius: var(--mk-radius-none)}:host([data-rounded=lg]){--_radius: var(--mk-radius-lg)}:host([data-rounded=circle]){--_radius: var(--mk-radius-circle)}:host([data-layout=masonry]){display:block;columns:var(--_cols);column-gap:var(--_gap)}:host([data-layout=masonry]) .mk-gallery__tile{width:100%;break-inside:avoid;margin-block-end:var(--_gap)}:host([data-layout=strip]){display:flex;gap:var(--_gap);overflow-x:auto;scroll-snap-type:x mandatory;padding-block-end:var(--mk-space-1)}:host([data-layout=strip]) .mk-gallery__tile{flex:0 0 calc((100% - (var(--_cols) - 1) * var(--_gap)) / var(--_cols));scroll-snap-align:start}.mk-gallery__tile{position:relative;display:block;min-width:0;margin:0;padding:0;border:0;background:none;color:inherit;font:inherit;border-radius:var(--_radius);cursor:pointer}.mk-gallery__tile:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-gallery__tile:after{content:\"\";position:absolute;inset:0;border-radius:var(--_radius);background-color:transparent;transition:background-color var(--mk-duration-fast) var(--mk-ease-standard);pointer-events:none}.mk-gallery__tile:hover:after{background-color:var(--mk-hover-overlay)}.mk-gallery__tile:active:after{background-color:var(--mk-active-overlay)}.mk-gallery__more{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;border-radius:var(--_radius);background-color:var(--mk-overlay-scrim);color:var(--mk-primary-contrast);font-size:var(--mk-font-size-xl);font-weight:var(--mk-font-weight-semibold);pointer-events:none}@media(prefers-reduced-motion:reduce){.mk-gallery__tile:after{transition:none}}\n"], dependencies: [{ kind: "component", type: MkImage, selector: "mk-image", inputs: ["src", "alt", "aspectRatio", "fit", "rounded", "caption", "lazy", "priority", "srcset", "sizes"], outputs: ["loaded", "errored"], exportAs: ["mkImage"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
357
|
+
}
|
|
358
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkImageGallery, decorators: [{
|
|
359
|
+
type: Component,
|
|
360
|
+
args: [{ selector: 'mk-image-gallery', exportAs: 'mkImageGallery', changeDetection: ChangeDetectionStrategy.OnPush, imports: [MkImage], host: {
|
|
361
|
+
class: 'mk-image-gallery',
|
|
362
|
+
'[attr.data-layout]': 'layout()',
|
|
363
|
+
'[attr.data-rounded]': 'rounded()',
|
|
364
|
+
'[style.--_cols]': 'columns()',
|
|
365
|
+
}, template: "@for (item of visibleItems(); track $index; let i = $index; let last = $last) {\n <button\n type=\"button\"\n class=\"mk-gallery__tile\"\n [attr.aria-label]=\"tileLabel(i)\"\n (click)=\"onTileClick(i)\"\n >\n <mk-image\n [src]=\"item.thumb ?? item.src\"\n [aspectRatio]=\"tileAspectRatio()\"\n [rounded]=\"rounded()\"\n />\n @if (last && overflowCount() > 0) {\n <span class=\"mk-gallery__more\" aria-hidden=\"true\"\n >+{{ overflowCount() }}</span\n >\n }\n </button>\n}\n", styles: [":host{--_cols: 3;--_gap: var(--mk-space-2);--_radius: var(--mk-radius-md);display:grid;grid-template-columns:repeat(var(--_cols),1fr);gap:var(--_gap);min-width:0;font-family:var(--mk-font-sans)}:host([data-rounded=none]){--_radius: var(--mk-radius-none)}:host([data-rounded=lg]){--_radius: var(--mk-radius-lg)}:host([data-rounded=circle]){--_radius: var(--mk-radius-circle)}:host([data-layout=masonry]){display:block;columns:var(--_cols);column-gap:var(--_gap)}:host([data-layout=masonry]) .mk-gallery__tile{width:100%;break-inside:avoid;margin-block-end:var(--_gap)}:host([data-layout=strip]){display:flex;gap:var(--_gap);overflow-x:auto;scroll-snap-type:x mandatory;padding-block-end:var(--mk-space-1)}:host([data-layout=strip]) .mk-gallery__tile{flex:0 0 calc((100% - (var(--_cols) - 1) * var(--_gap)) / var(--_cols));scroll-snap-align:start}.mk-gallery__tile{position:relative;display:block;min-width:0;margin:0;padding:0;border:0;background:none;color:inherit;font:inherit;border-radius:var(--_radius);cursor:pointer}.mk-gallery__tile:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-gallery__tile:after{content:\"\";position:absolute;inset:0;border-radius:var(--_radius);background-color:transparent;transition:background-color var(--mk-duration-fast) var(--mk-ease-standard);pointer-events:none}.mk-gallery__tile:hover:after{background-color:var(--mk-hover-overlay)}.mk-gallery__tile:active:after{background-color:var(--mk-active-overlay)}.mk-gallery__more{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;border-radius:var(--_radius);background-color:var(--mk-overlay-scrim);color:var(--mk-primary-contrast);font-size:var(--mk-font-size-xl);font-weight:var(--mk-font-weight-semibold);pointer-events:none}@media(prefers-reduced-motion:reduce){.mk-gallery__tile:after{transition:none}}\n"] }]
|
|
366
|
+
}], propDecorators: { items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: true }] }], layout: [{ type: i0.Input, args: [{ isSignal: true, alias: "layout", required: false }] }], columns: [{ type: i0.Input, args: [{ isSignal: true, alias: "columns", required: false }] }], max: [{ type: i0.Input, args: [{ isSignal: true, alias: "max", required: false }] }], lightbox: [{ type: i0.Input, args: [{ isSignal: true, alias: "lightbox", required: false }] }], aspectRatio: [{ type: i0.Input, args: [{ isSignal: true, alias: "aspectRatio", required: false }] }], rounded: [{ type: i0.Input, args: [{ isSignal: true, alias: "rounded", required: false }] }], itemClick: [{ type: i0.Output, args: ["itemClick"] }] } });
|
|
367
|
+
|
|
368
|
+
// --- Pure crop geometry -----------------------------------------------------
|
|
369
|
+
// jsdom has no canvas and never loads images, so everything numeric lives in
|
|
370
|
+
// these exported functions where it can be unit-tested directly.
|
|
371
|
+
/**
|
|
372
|
+
* Base cover scale for an image in a viewport — the smallest scale at which
|
|
373
|
+
* `imgW × imgH` fully covers `vpW × vpH` (object-fit: cover). Returns `1`
|
|
374
|
+
* for degenerate (non-positive) dimensions.
|
|
375
|
+
*/
|
|
376
|
+
function mkCoverScale(imgW, imgH, vpW, vpH) {
|
|
377
|
+
if (imgW <= 0 || imgH <= 0 || vpW <= 0 || vpH <= 0)
|
|
378
|
+
return 1;
|
|
379
|
+
return Math.max(vpW / imgW, vpH / imgH);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Clamp a pan offset (CSS px, measured from the viewport centre to the image
|
|
383
|
+
* centre) so the image scaled by `scale` still covers the viewport — no empty
|
|
384
|
+
* edges. When the scaled image is not larger than the viewport on an axis the
|
|
385
|
+
* offset collapses to `0` (centred).
|
|
386
|
+
*/
|
|
387
|
+
function mkClampPan(x, y, imgW, imgH, vpW, vpH, scale) {
|
|
388
|
+
const maxX = Math.max(0, (imgW * scale - vpW) / 2);
|
|
389
|
+
const maxY = Math.max(0, (imgH * scale - vpH) / 2);
|
|
390
|
+
// `+ 0` normalises the -0 produced when an axis has no slack.
|
|
391
|
+
return {
|
|
392
|
+
x: Math.min(maxX, Math.max(-maxX, x)) + 0,
|
|
393
|
+
y: Math.min(maxY, Math.max(-maxY, y)) + 0,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Source rect (in natural image pixels) of the crop region visible through
|
|
398
|
+
* the viewport. `scale` maps natural pixels to CSS pixels (cover scale ×
|
|
399
|
+
* zoom); `panX`/`panY` are the CSS-px offsets of the image centre from the
|
|
400
|
+
* viewport centre. The rect is clamped inside the image bounds.
|
|
401
|
+
*/
|
|
402
|
+
function mkCropRect(imgNaturalW, imgNaturalH, vpW, vpH, scale, panX, panY) {
|
|
403
|
+
if (imgNaturalW <= 0 || imgNaturalH <= 0 || vpW <= 0 || vpH <= 0 || scale <= 0) {
|
|
404
|
+
return { sx: 0, sy: 0, sw: Math.max(0, imgNaturalW), sh: Math.max(0, imgNaturalH) };
|
|
405
|
+
}
|
|
406
|
+
const sw = Math.min(vpW / scale, imgNaturalW);
|
|
407
|
+
const sh = Math.min(vpH / scale, imgNaturalH);
|
|
408
|
+
// The image point rendered at the viewport centre.
|
|
409
|
+
const cx = imgNaturalW / 2 - panX / scale;
|
|
410
|
+
const cy = imgNaturalH / 2 - panY / scale;
|
|
411
|
+
const sx = Math.min(Math.max(cx - sw / 2, 0), imgNaturalW - sw);
|
|
412
|
+
const sy = Math.min(Math.max(cy - sh / 2, 0), imgNaturalH - sh);
|
|
413
|
+
return { sx, sy, sw, sh };
|
|
414
|
+
}
|
|
415
|
+
/** Zoom step used by the −/+ buttons. */
|
|
416
|
+
const BUTTON_ZOOM_STEP = 0.25;
|
|
417
|
+
/** Zoom step used by the `+`/`-` keys. */
|
|
418
|
+
const KEY_ZOOM_STEP = 0.1;
|
|
419
|
+
/**
|
|
420
|
+
* ImageCropper — pan-and-zoom crop control for avatars, covers and media uploads.
|
|
421
|
+
* The image covers a fixed-aspect viewport (object-fit-cover style); the user
|
|
422
|
+
* drags to pan, scrolls / pinch-zooms via the slider or `+`/`−` controls, and
|
|
423
|
+
* `crop()` renders the visible region to a data-URL on an offscreen canvas.
|
|
424
|
+
*
|
|
425
|
+
* Pan is always clamped so the image keeps covering the viewport, a
|
|
426
|
+
* rule-of-thirds grid appears while dragging, and `round` overlays a circular
|
|
427
|
+
* scrim for avatar crops (the exported bitmap stays rectangular). The crop
|
|
428
|
+
* area is keyboard operable: Arrow keys pan by 10px (1px with Shift), `+`/`-`
|
|
429
|
+
* zoom.
|
|
430
|
+
*
|
|
431
|
+
* ```html
|
|
432
|
+
* <mk-image-cropper #cropper src="/assets/photo.jpg" [aspect]="1" round />
|
|
433
|
+
* <button mkButton (click)="avatar = cropper.crop({ size: 512 })">Save</button>
|
|
434
|
+
* ```
|
|
435
|
+
*/
|
|
436
|
+
class MkImageCropper {
|
|
437
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
438
|
+
/** Localised strings (override globally via `provideMkI18n`). */
|
|
439
|
+
i18n = inject(MK_I18N);
|
|
440
|
+
viewportRef = viewChild.required('viewport', /* @ts-ignore */
|
|
441
|
+
...(ngDevMode ? [{ debugName: "viewportRef" }] : /* istanbul ignore next */ []));
|
|
442
|
+
imageRef = viewChild('image', /* @ts-ignore */
|
|
443
|
+
...(ngDevMode ? [{ debugName: "imageRef" }] : /* istanbul ignore next */ []));
|
|
444
|
+
/** Image URL or data-URL to crop. */
|
|
445
|
+
src = input.required(/* @ts-ignore */
|
|
446
|
+
...(ngDevMode ? [{ debugName: "src" }] : /* istanbul ignore next */ []));
|
|
447
|
+
/**
|
|
448
|
+
* Aspect ratio (width / height) of the crop viewport, e.g. `1` for a
|
|
449
|
+
* square or `16 / 9`. `null` makes the viewport fill its container.
|
|
450
|
+
*/
|
|
451
|
+
aspect = input(1, /* @ts-ignore */
|
|
452
|
+
...(ngDevMode ? [{ debugName: "aspect" }] : /* istanbul ignore next */ []));
|
|
453
|
+
/**
|
|
454
|
+
* Circular mask overlay for avatar crops. Purely visual — the exported
|
|
455
|
+
* bitmap from `crop()` stays rectangular.
|
|
456
|
+
*/
|
|
457
|
+
round = input(false, { ...(ngDevMode ? { debugName: "round" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
458
|
+
/** Lowest allowed zoom factor. */
|
|
459
|
+
minZoom = input(1, { ...(ngDevMode ? { debugName: "minZoom" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
460
|
+
/** Highest allowed zoom factor. */
|
|
461
|
+
maxZoom = input(4, { ...(ngDevMode ? { debugName: "maxZoom" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
462
|
+
/** Disable panning and zooming. */
|
|
463
|
+
disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
464
|
+
/**
|
|
465
|
+
* `crossorigin` for the internal `<img>`. Set to `'anonymous'` when
|
|
466
|
+
* cropping CORS-enabled remote images — without it the canvas is tainted
|
|
467
|
+
* and `crop()` returns `null`. Same-origin and data-URL sources don't
|
|
468
|
+
* need it.
|
|
469
|
+
*/
|
|
470
|
+
crossOrigin = input(null, /* @ts-ignore */
|
|
471
|
+
...(ngDevMode ? [{ debugName: "crossOrigin" }] : /* istanbul ignore next */ []));
|
|
472
|
+
/** Accessible name for the crop area (defaults to the i18n `zoom` label). */
|
|
473
|
+
ariaLabel = input(null, /* @ts-ignore */
|
|
474
|
+
...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
|
|
475
|
+
/** Emits on any pan or zoom change. */
|
|
476
|
+
changed = output();
|
|
477
|
+
/** Emits when the image fails to load. */
|
|
478
|
+
imageError = output();
|
|
479
|
+
/** Two-way zoom factor, clamped to `[minZoom, maxZoom]`. */
|
|
480
|
+
zoom = model(1, /* @ts-ignore */
|
|
481
|
+
...(ngDevMode ? [{ debugName: "zoom" }] : /* istanbul ignore next */ []));
|
|
482
|
+
natW = signal(0, /* @ts-ignore */
|
|
483
|
+
...(ngDevMode ? [{ debugName: "natW" }] : /* istanbul ignore next */ []));
|
|
484
|
+
natH = signal(0, /* @ts-ignore */
|
|
485
|
+
...(ngDevMode ? [{ debugName: "natH" }] : /* istanbul ignore next */ []));
|
|
486
|
+
vpW = signal(0, /* @ts-ignore */
|
|
487
|
+
...(ngDevMode ? [{ debugName: "vpW" }] : /* istanbul ignore next */ []));
|
|
488
|
+
vpH = signal(0, /* @ts-ignore */
|
|
489
|
+
...(ngDevMode ? [{ debugName: "vpH" }] : /* istanbul ignore next */ []));
|
|
490
|
+
panX = signal(0, /* @ts-ignore */
|
|
491
|
+
...(ngDevMode ? [{ debugName: "panX" }] : /* istanbul ignore next */ []));
|
|
492
|
+
panY = signal(0, /* @ts-ignore */
|
|
493
|
+
...(ngDevMode ? [{ debugName: "panY" }] : /* istanbul ignore next */ []));
|
|
494
|
+
status = signal('loading', /* @ts-ignore */
|
|
495
|
+
...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
|
|
496
|
+
dragging = signal(false, /* @ts-ignore */
|
|
497
|
+
...(ngDevMode ? [{ debugName: "dragging" }] : /* istanbul ignore next */ []));
|
|
498
|
+
dragStart = null;
|
|
499
|
+
resizeObserver;
|
|
500
|
+
coverScale = computed(() => mkCoverScale(this.natW(), this.natH(), this.vpW(), this.vpH()), /* @ts-ignore */
|
|
501
|
+
...(ngDevMode ? [{ debugName: "coverScale" }] : /* istanbul ignore next */ []));
|
|
502
|
+
totalScale = computed(() => this.coverScale() * this.zoom(), /* @ts-ignore */
|
|
503
|
+
...(ngDevMode ? [{ debugName: "totalScale" }] : /* istanbul ignore next */ []));
|
|
504
|
+
/** CSS transform applied to the image element. */
|
|
505
|
+
imageTransform = computed(() => `translate(calc(-50% + ${this.panX()}px), calc(-50% + ${this.panY()}px)) ` +
|
|
506
|
+
`scale(${this.totalScale()})`, /* @ts-ignore */
|
|
507
|
+
...(ngDevMode ? [{ debugName: "imageTransform" }] : /* istanbul ignore next */ []));
|
|
508
|
+
constructor() {
|
|
509
|
+
// Restart from scratch whenever the source changes.
|
|
510
|
+
effect(() => {
|
|
511
|
+
this.src();
|
|
512
|
+
untracked(() => {
|
|
513
|
+
this.status.set('loading');
|
|
514
|
+
this.natW.set(0);
|
|
515
|
+
this.natH.set(0);
|
|
516
|
+
this.reset();
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
// Keep an externally-written zoom model inside [minZoom, maxZoom].
|
|
520
|
+
effect(() => {
|
|
521
|
+
const clamped = this.clampZoom(this.zoom());
|
|
522
|
+
if (clamped !== this.zoom())
|
|
523
|
+
this.zoom.set(clamped);
|
|
524
|
+
});
|
|
525
|
+
// Re-clamp pan when zoom, image or viewport geometry changes.
|
|
526
|
+
effect(() => {
|
|
527
|
+
const scale = this.totalScale();
|
|
528
|
+
const [imgW, imgH, vpW, vpH] = [this.natW(), this.natH(), this.vpW(), this.vpH()];
|
|
529
|
+
untracked(() => {
|
|
530
|
+
const p = mkClampPan(this.panX(), this.panY(), imgW, imgH, vpW, vpH, scale);
|
|
531
|
+
this.panX.set(p.x);
|
|
532
|
+
this.panY.set(p.y);
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
afterNextRender(() => {
|
|
536
|
+
this.measure();
|
|
537
|
+
const viewport = this.viewportRef().nativeElement;
|
|
538
|
+
// Angular's (wheel) binding may register passively; zooming must be
|
|
539
|
+
// able to preventDefault, so attach by hand.
|
|
540
|
+
viewport.addEventListener('wheel', this.onWheel, { passive: false });
|
|
541
|
+
if ('ResizeObserver' in window) {
|
|
542
|
+
this.resizeObserver = new ResizeObserver(() => this.measure());
|
|
543
|
+
this.resizeObserver.observe(viewport);
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
ngOnDestroy() {
|
|
548
|
+
this.resizeObserver?.disconnect();
|
|
549
|
+
if (this.isBrowser) {
|
|
550
|
+
this.viewportRef().nativeElement.removeEventListener('wheel', this.onWheel);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// --- Public API -----------------------------------------------------------
|
|
554
|
+
/** Centre the image and reset zoom to 1 (clamped to `minZoom`). */
|
|
555
|
+
reset() {
|
|
556
|
+
const zoom = this.clampZoom(1);
|
|
557
|
+
const dirty = this.zoom() !== zoom || this.panX() !== 0 || this.panY() !== 0;
|
|
558
|
+
this.zoom.set(zoom);
|
|
559
|
+
this.panX.set(0);
|
|
560
|
+
this.panY.set(0);
|
|
561
|
+
if (dirty)
|
|
562
|
+
this.changed.emit();
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Draw the visible crop region to an offscreen canvas and return it as a
|
|
566
|
+
* data-URL (longest edge capped at `options.size ?? 1024`). Returns `null`
|
|
567
|
+
* when the image is not loaded or the canvas is unavailable (SSR, jsdom).
|
|
568
|
+
*/
|
|
569
|
+
/**
|
|
570
|
+
* The visible crop region in natural image pixels — what `crop()` would
|
|
571
|
+
* rasterise, without touching a canvas. Use it when the backend performs
|
|
572
|
+
* the actual crop from the master image (no client-side quality loss).
|
|
573
|
+
* Returns `null` until the image has loaded and the viewport is measured.
|
|
574
|
+
*/
|
|
575
|
+
cropRect() {
|
|
576
|
+
if (this.status() !== 'loaded')
|
|
577
|
+
return null;
|
|
578
|
+
const [natW, natH, vpW, vpH] = [this.natW(), this.natH(), this.vpW(), this.vpH()];
|
|
579
|
+
if (!natW || !natH || !vpW || !vpH)
|
|
580
|
+
return null;
|
|
581
|
+
return mkCropRect(natW, natH, vpW, vpH, this.totalScale(), this.panX(), this.panY());
|
|
582
|
+
}
|
|
583
|
+
crop(options) {
|
|
584
|
+
const image = this.imageRef()?.nativeElement;
|
|
585
|
+
if (!this.isBrowser || this.status() !== 'loaded' || !image)
|
|
586
|
+
return null;
|
|
587
|
+
const [natW, natH, vpW, vpH] = [this.natW(), this.natH(), this.vpW(), this.vpH()];
|
|
588
|
+
if (!natW || !natH || !vpW || !vpH)
|
|
589
|
+
return null;
|
|
590
|
+
const { sx, sy, sw, sh } = mkCropRect(natW, natH, vpW, vpH, this.totalScale(), this.panX(), this.panY());
|
|
591
|
+
const cap = options?.size ?? 1024;
|
|
592
|
+
const factor = Math.min(1, cap / Math.max(sw, sh));
|
|
593
|
+
try {
|
|
594
|
+
const canvas = document.createElement('canvas');
|
|
595
|
+
canvas.width = Math.max(1, Math.round(sw * factor));
|
|
596
|
+
canvas.height = Math.max(1, Math.round(sh * factor));
|
|
597
|
+
const ctx = canvas.getContext('2d');
|
|
598
|
+
if (!ctx)
|
|
599
|
+
return null;
|
|
600
|
+
ctx.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
|
|
601
|
+
return canvas.toDataURL(options?.type ?? 'image/png', options?.quality) || null;
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
// Environments without canvas rasterisation (e.g. jsdom).
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// --- Image lifecycle ------------------------------------------------------
|
|
609
|
+
onImageLoad(event) {
|
|
610
|
+
const image = event.target;
|
|
611
|
+
this.natW.set(image.naturalWidth);
|
|
612
|
+
this.natH.set(image.naturalHeight);
|
|
613
|
+
this.status.set('loaded');
|
|
614
|
+
this.reset();
|
|
615
|
+
}
|
|
616
|
+
onImageError() {
|
|
617
|
+
this.status.set('error');
|
|
618
|
+
this.imageError.emit();
|
|
619
|
+
}
|
|
620
|
+
// --- Pointer panning ------------------------------------------------------
|
|
621
|
+
onPointerDown(event) {
|
|
622
|
+
if (this.disabled())
|
|
623
|
+
return;
|
|
624
|
+
try {
|
|
625
|
+
this.viewportRef().nativeElement.setPointerCapture(event.pointerId);
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
// jsdom / detached element.
|
|
629
|
+
}
|
|
630
|
+
this.dragStart = {
|
|
631
|
+
x: event.clientX,
|
|
632
|
+
y: event.clientY,
|
|
633
|
+
panX: this.panX(),
|
|
634
|
+
panY: this.panY(),
|
|
635
|
+
};
|
|
636
|
+
this.dragging.set(true);
|
|
637
|
+
event.preventDefault();
|
|
638
|
+
}
|
|
639
|
+
onPointerMove(event) {
|
|
640
|
+
const start = this.dragStart;
|
|
641
|
+
if (!start)
|
|
642
|
+
return;
|
|
643
|
+
this.panTo(start.panX + event.clientX - start.x, start.panY + event.clientY - start.y);
|
|
644
|
+
}
|
|
645
|
+
onPointerUp() {
|
|
646
|
+
this.dragStart = null;
|
|
647
|
+
this.dragging.set(false);
|
|
648
|
+
}
|
|
649
|
+
// --- Zooming --------------------------------------------------------------
|
|
650
|
+
onWheel = (event) => {
|
|
651
|
+
if (this.disabled())
|
|
652
|
+
return;
|
|
653
|
+
event.preventDefault();
|
|
654
|
+
// Multiplicative steps feel uniform across the whole zoom range.
|
|
655
|
+
this.zoomTo(this.zoom() * Math.exp(-event.deltaY * 0.0015));
|
|
656
|
+
};
|
|
657
|
+
onRangeInput(event) {
|
|
658
|
+
const value = Number(event.target.value);
|
|
659
|
+
if (!Number.isNaN(value))
|
|
660
|
+
this.zoomTo(value);
|
|
661
|
+
}
|
|
662
|
+
stepZoom(direction) {
|
|
663
|
+
this.zoomTo(this.zoom() + direction * BUTTON_ZOOM_STEP);
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Zoom towards the viewport centre: the pan offset scales with the zoom
|
|
667
|
+
* ratio so the image point at the centre stays put, then re-clamps.
|
|
668
|
+
*/
|
|
669
|
+
zoomTo(next) {
|
|
670
|
+
const clamped = this.clampZoom(next);
|
|
671
|
+
const previous = this.zoom();
|
|
672
|
+
if (clamped === previous)
|
|
673
|
+
return;
|
|
674
|
+
const ratio = clamped / previous;
|
|
675
|
+
this.zoom.set(clamped);
|
|
676
|
+
const p = mkClampPan(this.panX() * ratio, this.panY() * ratio, this.natW(), this.natH(), this.vpW(), this.vpH(), this.coverScale() * clamped);
|
|
677
|
+
this.panX.set(p.x);
|
|
678
|
+
this.panY.set(p.y);
|
|
679
|
+
this.changed.emit();
|
|
680
|
+
}
|
|
681
|
+
// --- Keyboard -------------------------------------------------------------
|
|
682
|
+
onKeydown(event) {
|
|
683
|
+
if (this.disabled())
|
|
684
|
+
return;
|
|
685
|
+
const step = event.shiftKey ? 1 : 10;
|
|
686
|
+
switch (event.key) {
|
|
687
|
+
case 'ArrowLeft':
|
|
688
|
+
this.panTo(this.panX() - step, this.panY());
|
|
689
|
+
break;
|
|
690
|
+
case 'ArrowRight':
|
|
691
|
+
this.panTo(this.panX() + step, this.panY());
|
|
692
|
+
break;
|
|
693
|
+
case 'ArrowUp':
|
|
694
|
+
this.panTo(this.panX(), this.panY() - step);
|
|
695
|
+
break;
|
|
696
|
+
case 'ArrowDown':
|
|
697
|
+
this.panTo(this.panX(), this.panY() + step);
|
|
698
|
+
break;
|
|
699
|
+
case '+':
|
|
700
|
+
case '=':
|
|
701
|
+
this.zoomTo(this.zoom() + KEY_ZOOM_STEP);
|
|
702
|
+
break;
|
|
703
|
+
case '-':
|
|
704
|
+
case '_':
|
|
705
|
+
this.zoomTo(this.zoom() - KEY_ZOOM_STEP);
|
|
706
|
+
break;
|
|
707
|
+
default:
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
event.preventDefault();
|
|
711
|
+
}
|
|
712
|
+
// --- Internals ------------------------------------------------------------
|
|
713
|
+
clampZoom(value) {
|
|
714
|
+
return Math.min(this.maxZoom(), Math.max(this.minZoom(), value));
|
|
715
|
+
}
|
|
716
|
+
panTo(x, y) {
|
|
717
|
+
const p = mkClampPan(x, y, this.natW(), this.natH(), this.vpW(), this.vpH(), this.totalScale());
|
|
718
|
+
if (p.x === this.panX() && p.y === this.panY())
|
|
719
|
+
return;
|
|
720
|
+
this.panX.set(p.x);
|
|
721
|
+
this.panY.set(p.y);
|
|
722
|
+
this.changed.emit();
|
|
723
|
+
}
|
|
724
|
+
/** Capture the viewport's CSS size (initial render + every resize). */
|
|
725
|
+
measure() {
|
|
726
|
+
if (!this.isBrowser)
|
|
727
|
+
return;
|
|
728
|
+
const rect = this.viewportRef().nativeElement.getBoundingClientRect();
|
|
729
|
+
this.vpW.set(rect.width);
|
|
730
|
+
this.vpH.set(rect.height);
|
|
731
|
+
}
|
|
732
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkImageCropper, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
733
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkImageCropper, isStandalone: true, selector: "mk-image-cropper", inputs: { src: { classPropertyName: "src", publicName: "src", isSignal: true, isRequired: true, transformFunction: null }, aspect: { classPropertyName: "aspect", publicName: "aspect", isSignal: true, isRequired: false, transformFunction: null }, round: { classPropertyName: "round", publicName: "round", isSignal: true, isRequired: false, transformFunction: null }, minZoom: { classPropertyName: "minZoom", publicName: "minZoom", isSignal: true, isRequired: false, transformFunction: null }, maxZoom: { classPropertyName: "maxZoom", publicName: "maxZoom", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, crossOrigin: { classPropertyName: "crossOrigin", publicName: "crossOrigin", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, zoom: { classPropertyName: "zoom", publicName: "zoom", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { changed: "changed", imageError: "imageError", zoom: "zoomChange" }, host: { properties: { "class.mk-image-cropper--disabled": "disabled()", "class.mk-image-cropper--round": "round()" }, classAttribute: "mk-image-cropper" }, viewQueries: [{ propertyName: "viewportRef", first: true, predicate: ["viewport"], descendants: true, isSignal: true }, { propertyName: "imageRef", first: true, predicate: ["image"], descendants: true, isSignal: true }], exportAs: ["mkImageCropper"], ngImport: i0, template: "<div\n #viewport\n class=\"mk-image-cropper__viewport\"\n role=\"application\"\n [attr.tabindex]=\"disabled() ? -1 : 0\"\n [attr.aria-label]=\"ariaLabel() || i18n.zoom\"\n [attr.aria-disabled]=\"disabled() || null\"\n [class.mk-image-cropper__viewport--fill]=\"aspect() === null\"\n [class.mk-image-cropper__viewport--dragging]=\"dragging()\"\n [style.aspect-ratio]=\"aspect()\"\n (pointerdown)=\"onPointerDown($event)\"\n (pointermove)=\"onPointerMove($event)\"\n (pointerup)=\"onPointerUp()\"\n (pointercancel)=\"onPointerUp()\"\n (keydown)=\"onKeydown($event)\"\n>\n @if (status() === 'error') {\n <div class=\"mk-image-cropper__error\">{{ i18n.imageFailed }}</div>\n } @else {\n <img\n #image\n class=\"mk-image-cropper__image\"\n [class.mk-image-cropper__image--ready]=\"status() === 'loaded'\"\n [src]=\"src()\"\n alt=\"\"\n draggable=\"false\"\n [attr.crossorigin]=\"crossOrigin()\"\n [style.transform]=\"imageTransform()\"\n (load)=\"onImageLoad($event)\"\n (error)=\"onImageError()\"\n />\n <div class=\"mk-image-cropper__grid\" aria-hidden=\"true\"></div>\n @if (round()) {\n <div class=\"mk-image-cropper__mask\" aria-hidden=\"true\"></div>\n }\n }\n</div>\n\n<div class=\"mk-image-cropper__controls\">\n <button\n type=\"button\"\n class=\"mk-image-cropper__zoom-btn\"\n [attr.aria-label]=\"i18n.zoomOut\"\n [disabled]=\"disabled() || zoom() <= minZoom()\"\n (click)=\"stepZoom(-1)\"\n >\n <span aria-hidden=\"true\">−</span>\n </button>\n <input\n type=\"range\"\n class=\"mk-image-cropper__range\"\n step=\"0.01\"\n [min]=\"minZoom()\"\n [max]=\"maxZoom()\"\n [value]=\"zoom()\"\n [disabled]=\"disabled()\"\n [attr.aria-label]=\"i18n.zoom\"\n (input)=\"onRangeInput($event)\"\n />\n <button\n type=\"button\"\n class=\"mk-image-cropper__zoom-btn\"\n [attr.aria-label]=\"i18n.zoomIn\"\n [disabled]=\"disabled() || zoom() >= maxZoom()\"\n (click)=\"stepZoom(1)\"\n >\n <span aria-hidden=\"true\">+</span>\n </button>\n</div>\n", styles: [":host{display:block;width:100%;font-family:var(--mk-font-sans)}.mk-image-cropper__viewport{position:relative;width:100%;overflow:hidden;background-color:var(--mk-surface-2);border:var(--mk-border-width) solid var(--mk-border);border-radius:var(--mk-radius-md);cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none}.mk-image-cropper__viewport--fill{height:100%}.mk-image-cropper__viewport--dragging{cursor:grabbing}.mk-image-cropper__viewport:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset);border-color:var(--mk-primary)}:host(.mk-image-cropper--disabled) .mk-image-cropper__viewport{cursor:not-allowed;opacity:.7}.mk-image-cropper__image{position:absolute;top:50%;left:50%;width:auto;height:auto;max-width:none;max-height:none;transform-origin:center;will-change:transform;pointer-events:none;-webkit-user-select:none;user-select:none;opacity:0;transition:opacity var(--mk-duration-fast) var(--mk-ease-standard)}.mk-image-cropper__image--ready{opacity:1}.mk-image-cropper__grid{--_grid-line: color-mix(in srgb, var(--mk-surface) 75%, transparent);position:absolute;inset:0;pointer-events:none;background-image:linear-gradient(var(--_grid-line),var(--_grid-line)),linear-gradient(var(--_grid-line),var(--_grid-line)),linear-gradient(var(--_grid-line),var(--_grid-line)),linear-gradient(var(--_grid-line),var(--_grid-line));background-repeat:no-repeat;background-position:33.333% 0,66.667% 0,0 33.333%,0 66.667%;background-size:1px 100%,1px 100%,100% 1px,100% 1px;opacity:0;transition:opacity var(--mk-duration-fast) var(--mk-ease-standard)}.mk-image-cropper__viewport--dragging .mk-image-cropper__grid{opacity:1}.mk-image-cropper__mask{position:absolute;inset:0;pointer-events:none;background:radial-gradient(circle closest-side at 50% 50%,transparent calc(100% - 1px),color-mix(in srgb,var(--mk-bg) 50%,transparent) 100%)}.mk-image-cropper__error{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:var(--mk-space-4);color:var(--mk-text-muted);font-size:var(--mk-font-size-sm);text-align:center}.mk-image-cropper__controls{display:flex;align-items:center;gap:var(--mk-space-3);margin-top:var(--mk-space-3)}.mk-image-cropper__zoom-btn{display:inline-flex;flex:none;align-items:center;justify-content:center;width:var(--mk-control-height-sm);height:var(--mk-control-height-sm);padding:0;font-family:inherit;font-size:var(--mk-font-size-lg);font-weight:var(--mk-font-weight-medium);line-height:1;color:var(--mk-text-muted);background-color:var(--mk-surface);border:var(--mk-border-width) solid var(--mk-border);border-radius:var(--mk-radius-sm);cursor:pointer;transition:color var(--mk-duration-fast) var(--mk-ease-standard),border-color var(--mk-duration-fast) var(--mk-ease-standard),background-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-image-cropper__zoom-btn:hover:not(:disabled){color:var(--mk-text);background-color:var(--mk-surface-2);border-color:var(--mk-border-strong)}.mk-image-cropper__zoom-btn:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-image-cropper__zoom-btn:disabled{color:var(--mk-text-disabled);cursor:not-allowed}.mk-image-cropper__range{--_track-h: .4rem;--_thumb: 1.15rem;flex:1 1 auto;min-width:0;height:var(--mk-control-height-sm);margin:0;appearance:none;background:transparent;cursor:pointer}.mk-image-cropper__range::-webkit-slider-runnable-track{height:var(--_track-h);background-color:var(--mk-surface-3);border-radius:var(--mk-radius-pill)}.mk-image-cropper__range::-webkit-slider-thumb{appearance:none;width:var(--_thumb);height:var(--_thumb);margin-top:calc((var(--_track-h) - var(--_thumb)) / 2);background-color:var(--mk-surface);border:var(--mk-border-width-strong) solid var(--mk-primary);border-radius:var(--mk-radius-circle);box-shadow:var(--mk-shadow-sm)}.mk-image-cropper__range::-moz-range-track{height:var(--_track-h);background-color:var(--mk-surface-3);border-radius:var(--mk-radius-pill)}.mk-image-cropper__range::-moz-range-thumb{width:var(--_thumb);height:var(--_thumb);background-color:var(--mk-surface);border:var(--mk-border-width-strong) solid var(--mk-primary);border-radius:var(--mk-radius-circle);box-shadow:var(--mk-shadow-sm)}.mk-image-cropper__range:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-image-cropper__range:disabled{cursor:not-allowed;opacity:.6}@media(prefers-reduced-motion:reduce){.mk-image-cropper__image,.mk-image-cropper__grid,.mk-image-cropper__zoom-btn{transition:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
734
|
+
}
|
|
735
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkImageCropper, decorators: [{
|
|
736
|
+
type: Component,
|
|
737
|
+
args: [{ selector: 'mk-image-cropper', exportAs: 'mkImageCropper', changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
738
|
+
class: 'mk-image-cropper',
|
|
739
|
+
'[class.mk-image-cropper--disabled]': 'disabled()',
|
|
740
|
+
'[class.mk-image-cropper--round]': 'round()',
|
|
741
|
+
}, template: "<div\n #viewport\n class=\"mk-image-cropper__viewport\"\n role=\"application\"\n [attr.tabindex]=\"disabled() ? -1 : 0\"\n [attr.aria-label]=\"ariaLabel() || i18n.zoom\"\n [attr.aria-disabled]=\"disabled() || null\"\n [class.mk-image-cropper__viewport--fill]=\"aspect() === null\"\n [class.mk-image-cropper__viewport--dragging]=\"dragging()\"\n [style.aspect-ratio]=\"aspect()\"\n (pointerdown)=\"onPointerDown($event)\"\n (pointermove)=\"onPointerMove($event)\"\n (pointerup)=\"onPointerUp()\"\n (pointercancel)=\"onPointerUp()\"\n (keydown)=\"onKeydown($event)\"\n>\n @if (status() === 'error') {\n <div class=\"mk-image-cropper__error\">{{ i18n.imageFailed }}</div>\n } @else {\n <img\n #image\n class=\"mk-image-cropper__image\"\n [class.mk-image-cropper__image--ready]=\"status() === 'loaded'\"\n [src]=\"src()\"\n alt=\"\"\n draggable=\"false\"\n [attr.crossorigin]=\"crossOrigin()\"\n [style.transform]=\"imageTransform()\"\n (load)=\"onImageLoad($event)\"\n (error)=\"onImageError()\"\n />\n <div class=\"mk-image-cropper__grid\" aria-hidden=\"true\"></div>\n @if (round()) {\n <div class=\"mk-image-cropper__mask\" aria-hidden=\"true\"></div>\n }\n }\n</div>\n\n<div class=\"mk-image-cropper__controls\">\n <button\n type=\"button\"\n class=\"mk-image-cropper__zoom-btn\"\n [attr.aria-label]=\"i18n.zoomOut\"\n [disabled]=\"disabled() || zoom() <= minZoom()\"\n (click)=\"stepZoom(-1)\"\n >\n <span aria-hidden=\"true\">−</span>\n </button>\n <input\n type=\"range\"\n class=\"mk-image-cropper__range\"\n step=\"0.01\"\n [min]=\"minZoom()\"\n [max]=\"maxZoom()\"\n [value]=\"zoom()\"\n [disabled]=\"disabled()\"\n [attr.aria-label]=\"i18n.zoom\"\n (input)=\"onRangeInput($event)\"\n />\n <button\n type=\"button\"\n class=\"mk-image-cropper__zoom-btn\"\n [attr.aria-label]=\"i18n.zoomIn\"\n [disabled]=\"disabled() || zoom() >= maxZoom()\"\n (click)=\"stepZoom(1)\"\n >\n <span aria-hidden=\"true\">+</span>\n </button>\n</div>\n", styles: [":host{display:block;width:100%;font-family:var(--mk-font-sans)}.mk-image-cropper__viewport{position:relative;width:100%;overflow:hidden;background-color:var(--mk-surface-2);border:var(--mk-border-width) solid var(--mk-border);border-radius:var(--mk-radius-md);cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none}.mk-image-cropper__viewport--fill{height:100%}.mk-image-cropper__viewport--dragging{cursor:grabbing}.mk-image-cropper__viewport:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset);border-color:var(--mk-primary)}:host(.mk-image-cropper--disabled) .mk-image-cropper__viewport{cursor:not-allowed;opacity:.7}.mk-image-cropper__image{position:absolute;top:50%;left:50%;width:auto;height:auto;max-width:none;max-height:none;transform-origin:center;will-change:transform;pointer-events:none;-webkit-user-select:none;user-select:none;opacity:0;transition:opacity var(--mk-duration-fast) var(--mk-ease-standard)}.mk-image-cropper__image--ready{opacity:1}.mk-image-cropper__grid{--_grid-line: color-mix(in srgb, var(--mk-surface) 75%, transparent);position:absolute;inset:0;pointer-events:none;background-image:linear-gradient(var(--_grid-line),var(--_grid-line)),linear-gradient(var(--_grid-line),var(--_grid-line)),linear-gradient(var(--_grid-line),var(--_grid-line)),linear-gradient(var(--_grid-line),var(--_grid-line));background-repeat:no-repeat;background-position:33.333% 0,66.667% 0,0 33.333%,0 66.667%;background-size:1px 100%,1px 100%,100% 1px,100% 1px;opacity:0;transition:opacity var(--mk-duration-fast) var(--mk-ease-standard)}.mk-image-cropper__viewport--dragging .mk-image-cropper__grid{opacity:1}.mk-image-cropper__mask{position:absolute;inset:0;pointer-events:none;background:radial-gradient(circle closest-side at 50% 50%,transparent calc(100% - 1px),color-mix(in srgb,var(--mk-bg) 50%,transparent) 100%)}.mk-image-cropper__error{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:var(--mk-space-4);color:var(--mk-text-muted);font-size:var(--mk-font-size-sm);text-align:center}.mk-image-cropper__controls{display:flex;align-items:center;gap:var(--mk-space-3);margin-top:var(--mk-space-3)}.mk-image-cropper__zoom-btn{display:inline-flex;flex:none;align-items:center;justify-content:center;width:var(--mk-control-height-sm);height:var(--mk-control-height-sm);padding:0;font-family:inherit;font-size:var(--mk-font-size-lg);font-weight:var(--mk-font-weight-medium);line-height:1;color:var(--mk-text-muted);background-color:var(--mk-surface);border:var(--mk-border-width) solid var(--mk-border);border-radius:var(--mk-radius-sm);cursor:pointer;transition:color var(--mk-duration-fast) var(--mk-ease-standard),border-color var(--mk-duration-fast) var(--mk-ease-standard),background-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-image-cropper__zoom-btn:hover:not(:disabled){color:var(--mk-text);background-color:var(--mk-surface-2);border-color:var(--mk-border-strong)}.mk-image-cropper__zoom-btn:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-image-cropper__zoom-btn:disabled{color:var(--mk-text-disabled);cursor:not-allowed}.mk-image-cropper__range{--_track-h: .4rem;--_thumb: 1.15rem;flex:1 1 auto;min-width:0;height:var(--mk-control-height-sm);margin:0;appearance:none;background:transparent;cursor:pointer}.mk-image-cropper__range::-webkit-slider-runnable-track{height:var(--_track-h);background-color:var(--mk-surface-3);border-radius:var(--mk-radius-pill)}.mk-image-cropper__range::-webkit-slider-thumb{appearance:none;width:var(--_thumb);height:var(--_thumb);margin-top:calc((var(--_track-h) - var(--_thumb)) / 2);background-color:var(--mk-surface);border:var(--mk-border-width-strong) solid var(--mk-primary);border-radius:var(--mk-radius-circle);box-shadow:var(--mk-shadow-sm)}.mk-image-cropper__range::-moz-range-track{height:var(--_track-h);background-color:var(--mk-surface-3);border-radius:var(--mk-radius-pill)}.mk-image-cropper__range::-moz-range-thumb{width:var(--_thumb);height:var(--_thumb);background-color:var(--mk-surface);border:var(--mk-border-width-strong) solid var(--mk-primary);border-radius:var(--mk-radius-circle);box-shadow:var(--mk-shadow-sm)}.mk-image-cropper__range:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-image-cropper__range:disabled{cursor:not-allowed;opacity:.6}@media(prefers-reduced-motion:reduce){.mk-image-cropper__image,.mk-image-cropper__grid,.mk-image-cropper__zoom-btn{transition:none}}\n"] }]
|
|
742
|
+
}], ctorParameters: () => [], propDecorators: { viewportRef: [{ type: i0.ViewChild, args: ['viewport', { isSignal: true }] }], imageRef: [{ type: i0.ViewChild, args: ['image', { isSignal: true }] }], src: [{ type: i0.Input, args: [{ isSignal: true, alias: "src", required: true }] }], aspect: [{ type: i0.Input, args: [{ isSignal: true, alias: "aspect", required: false }] }], round: [{ type: i0.Input, args: [{ isSignal: true, alias: "round", required: false }] }], minZoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "minZoom", required: false }] }], maxZoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxZoom", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], crossOrigin: [{ type: i0.Input, args: [{ isSignal: true, alias: "crossOrigin", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], changed: [{ type: i0.Output, args: ["changed"] }], imageError: [{ type: i0.Output, args: ["imageError"] }], zoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoom", required: false }] }, { type: i0.Output, args: ["zoomChange"] }] } });
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Marks an `<ng-template>` as the per-tile action bar for {@link MkMediaGallery}.
|
|
746
|
+
* The template's implicit context is the {@link MkMediaItem}, so consumers can
|
|
747
|
+
* destructure it with `let-item`. The gallery renders it into each tile's
|
|
748
|
+
* top-right action bar (revealed on hover / focus-within, always visible on
|
|
749
|
+
* touch devices); the consumer supplies the buttons and their handlers.
|
|
750
|
+
*
|
|
751
|
+
* ```html
|
|
752
|
+
* <mk-media-gallery [items]="assets()">
|
|
753
|
+
* <ng-template mkMediaActions let-item>
|
|
754
|
+
* <button mkButton size="sm" variant="ghost" (click)="edit(item)">Edit</button>
|
|
755
|
+
* </ng-template>
|
|
756
|
+
* </mk-media-gallery>
|
|
757
|
+
* ```
|
|
758
|
+
*/
|
|
759
|
+
class MkMediaActions {
|
|
760
|
+
/** The projected action-bar template, rendered once per tile. */
|
|
761
|
+
template = inject(TemplateRef);
|
|
762
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkMediaActions, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
763
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.7", type: MkMediaActions, isStandalone: true, selector: "[mkMediaActions]", ngImport: i0 });
|
|
764
|
+
}
|
|
765
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkMediaActions, decorators: [{
|
|
766
|
+
type: Directive,
|
|
767
|
+
args: [{
|
|
768
|
+
selector: '[mkMediaActions]',
|
|
769
|
+
}]
|
|
770
|
+
}] });
|
|
771
|
+
/**
|
|
772
|
+
* Media gallery — a management grid of image tiles for admin media libraries.
|
|
773
|
+
* Each tile shows a square thumbnail (a labelled preview button emitting
|
|
774
|
+
* {@link itemClick}), the item's name and optional meta line, an optional
|
|
775
|
+
* selection checkbox (two-way `selection` model of selected ids) and an
|
|
776
|
+
* optional consumer-projected action bar ({@link MkMediaActions}).
|
|
777
|
+
*
|
|
778
|
+
* With `reorderable`, tiles become draggable via the dnd group's
|
|
779
|
+
* `[mkDropList]` / `[mkDrag]` directives — pointer and keyboard dragging
|
|
780
|
+
* (WCAG 2.1.1) both included. Each drop immutably updates the two-way `items`
|
|
781
|
+
* model and emits {@link reordered}.
|
|
782
|
+
*
|
|
783
|
+
* Clicking a tile always means "open/preview" — selection only toggles through
|
|
784
|
+
* the checkbox, never on tile click.
|
|
785
|
+
*
|
|
786
|
+
* ```html
|
|
787
|
+
* <mk-media-gallery
|
|
788
|
+
* [(items)]="assets"
|
|
789
|
+
* [(selection)]="selectedIds"
|
|
790
|
+
* selectable
|
|
791
|
+
* reorderable
|
|
792
|
+
* [columns]="5"
|
|
793
|
+
* (itemClick)="openPreview($event)"
|
|
794
|
+
* (reordered)="persistOrder($event)"
|
|
795
|
+
* >
|
|
796
|
+
* <ng-template mkMediaActions let-item>
|
|
797
|
+
* <button mkButton size="sm" variant="ghost" (click)="edit(item)">Edit</button>
|
|
798
|
+
* <button mkButton size="sm" variant="ghost" tone="danger" (click)="remove(item)">
|
|
799
|
+
* Delete
|
|
800
|
+
* </button>
|
|
801
|
+
* </ng-template>
|
|
802
|
+
* </mk-media-gallery>
|
|
803
|
+
* ```
|
|
804
|
+
*/
|
|
805
|
+
class MkMediaGallery {
|
|
806
|
+
/** Localized strings (region label, checkbox/preview labels, empty state). */
|
|
807
|
+
i18n = inject(MK_I18N);
|
|
808
|
+
/** The gallery's items (two-way). Replaced immutably on every reorder drop. */
|
|
809
|
+
items = model([], /* @ts-ignore */
|
|
810
|
+
...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
|
|
811
|
+
/** Ids of the currently selected items (two-way). */
|
|
812
|
+
selection = model([], /* @ts-ignore */
|
|
813
|
+
...(ngDevMode ? [{ debugName: "selection" }] : /* istanbul ignore next */ []));
|
|
814
|
+
/** Show a selection checkbox on every tile. */
|
|
815
|
+
selectable = input(false, { ...(ngDevMode ? { debugName: "selectable" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
816
|
+
/** Allow drag-and-drop (pointer + keyboard) reordering of the tiles. */
|
|
817
|
+
reorderable = input(false, { ...(ngDevMode ? { debugName: "reorderable" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
818
|
+
/** Number of grid columns. */
|
|
819
|
+
columns = input(4, { ...(ngDevMode ? { debugName: "columns" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
820
|
+
/** Accessible name of the gallery region. Defaults to `i18n.mediaLibrary`. */
|
|
821
|
+
ariaLabel = input(this.i18n.mediaLibrary, /* @ts-ignore */
|
|
822
|
+
...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
|
|
823
|
+
/** Emitted when a tile's preview button is activated (click/Enter/Space). */
|
|
824
|
+
itemClick = output();
|
|
825
|
+
/** Emitted after a drop has updated the `items` model. */
|
|
826
|
+
reordered = output();
|
|
827
|
+
/** Optional consumer-projected per-tile action bar (`$implicit` = item). */
|
|
828
|
+
actions = contentChild(MkMediaActions, /* @ts-ignore */
|
|
829
|
+
...(ngDevMode ? [{ debugName: "actions" }] : /* istanbul ignore next */ []));
|
|
830
|
+
/** Whether the item with `id` is currently selected. */
|
|
831
|
+
isSelected(id) {
|
|
832
|
+
return this.selection().includes(id);
|
|
833
|
+
}
|
|
834
|
+
/** Sync the two-way `selection` model with the checkbox's native state. */
|
|
835
|
+
onCheckboxChange(item, event) {
|
|
836
|
+
const checked = event.target.checked;
|
|
837
|
+
const current = this.selection();
|
|
838
|
+
if (checked) {
|
|
839
|
+
if (!current.includes(item.id))
|
|
840
|
+
this.selection.set([...current, item.id]);
|
|
841
|
+
}
|
|
842
|
+
else {
|
|
843
|
+
this.selection.set(current.filter((id) => id !== item.id));
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
/** Emit {@link itemClick} for the tile's preview button. */
|
|
847
|
+
onPreviewClick(item) {
|
|
848
|
+
this.itemClick.emit(item);
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Apply a drop from the dnd module: move the item on a copy of `items`,
|
|
852
|
+
* write the copy back to the two-way model and emit {@link reordered}.
|
|
853
|
+
*/
|
|
854
|
+
onDrop(event) {
|
|
855
|
+
const from = event.previousIndex;
|
|
856
|
+
const to = event.currentIndex;
|
|
857
|
+
if (from === to)
|
|
858
|
+
return;
|
|
859
|
+
const next = [...this.items()];
|
|
860
|
+
mkMoveItemInArray(next, from, to);
|
|
861
|
+
this.items.set(next);
|
|
862
|
+
this.reordered.emit({ from, to, items: next });
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Keep events from the tile's nested controls (checkbox, action buttons)
|
|
866
|
+
* inside those controls: without this, Space/Enter on the checkbox or an
|
|
867
|
+
* action button would bubble to the surrounding `[mkDrag]` host and pick the
|
|
868
|
+
* tile up instead, and a pointer press would start a drag session. Native
|
|
869
|
+
* behavior is untouched — only propagation stops.
|
|
870
|
+
*/
|
|
871
|
+
stopEvent(event) {
|
|
872
|
+
event.stopPropagation();
|
|
873
|
+
}
|
|
874
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkMediaGallery, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
875
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkMediaGallery, isStandalone: true, selector: "mk-media-gallery", inputs: { items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, selection: { classPropertyName: "selection", publicName: "selection", isSignal: true, isRequired: false, transformFunction: null }, selectable: { classPropertyName: "selectable", publicName: "selectable", isSignal: true, isRequired: false, transformFunction: null }, reorderable: { classPropertyName: "reorderable", publicName: "reorderable", isSignal: true, isRequired: false, transformFunction: null }, columns: { classPropertyName: "columns", publicName: "columns", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { items: "itemsChange", selection: "selectionChange", itemClick: "itemClick", reordered: "reordered" }, host: { attributes: { "role": "group" }, properties: { "attr.aria-label": "ariaLabel()" }, classAttribute: "mk-media-gallery" }, queries: [{ propertyName: "actions", first: true, predicate: MkMediaActions, descendants: true, isSignal: true }], exportAs: ["mkMediaGallery"], ngImport: i0, template: "@if (items().length === 0) {\n <div class=\"mk-media-gallery__empty\">{{ i18n.noData }}</div>\n} @else if (reorderable()) {\n <div\n mkDropList\n class=\"mk-media-gallery__grid\"\n [style.--_cols]=\"columns()\"\n [mkDropListData]=\"items()\"\n [mkDropListLabel]=\"ariaLabel()\"\n mkDropListOrientation=\"horizontal\"\n (mkDropListDropped)=\"onDrop($event)\"\n >\n @for (item of items(); track item.id) {\n <div\n mkDrag\n class=\"mk-media-gallery__tile\"\n [class.mk-media-gallery__tile--selected]=\"isSelected(item.id)\"\n [mkDragData]=\"item\"\n >\n <ng-container\n [ngTemplateOutlet]=\"tile\"\n [ngTemplateOutletContext]=\"{ $implicit: item }\"\n />\n </div>\n }\n </div>\n} @else {\n <div class=\"mk-media-gallery__grid\" [style.--_cols]=\"columns()\">\n @for (item of items(); track item.id) {\n <div\n class=\"mk-media-gallery__tile\"\n [class.mk-media-gallery__tile--selected]=\"isSelected(item.id)\"\n >\n <ng-container\n [ngTemplateOutlet]=\"tile\"\n [ngTemplateOutletContext]=\"{ $implicit: item }\"\n />\n </div>\n }\n </div>\n}\n\n<ng-template #tile let-item>\n <div class=\"mk-media-gallery__media\">\n <button\n type=\"button\"\n class=\"mk-media-gallery__preview\"\n [attr.aria-label]=\"i18n.viewImage(item.alt ?? item.name)\"\n (click)=\"onPreviewClick(item)\"\n (keydown)=\"stopEvent($event)\"\n >\n <img\n class=\"mk-media-gallery__img\"\n [src]=\"item.thumb ?? item.src\"\n [alt]=\"item.alt ?? ''\"\n loading=\"lazy\"\n draggable=\"false\"\n />\n </button>\n\n @if (selectable()) {\n <span\n class=\"mk-media-gallery__check\"\n (keydown)=\"stopEvent($event)\"\n (pointerdown)=\"stopEvent($event)\"\n >\n <input\n type=\"checkbox\"\n class=\"mk-media-gallery__check-input\"\n [checked]=\"isSelected(item.id)\"\n [attr.aria-label]=\"i18n.selectRow(item.name)\"\n (change)=\"onCheckboxChange(item, $event)\"\n />\n <span class=\"mk-media-gallery__check-box\" aria-hidden=\"true\">\n <span class=\"mk-media-gallery__check-mark\"></span>\n </span>\n </span>\n }\n\n @if (actions(); as actionsDir) {\n <div\n class=\"mk-media-gallery__actions\"\n (keydown)=\"stopEvent($event)\"\n (pointerdown)=\"stopEvent($event)\"\n >\n <ng-container\n [ngTemplateOutlet]=\"actionsDir.template\"\n [ngTemplateOutletContext]=\"{ $implicit: item }\"\n />\n </div>\n }\n </div>\n\n <div class=\"mk-media-gallery__caption\">\n <span class=\"mk-media-gallery__name\">{{ item.name }}</span>\n @if (item.meta) {\n <span class=\"mk-media-gallery__meta\">{{ item.meta }}</span>\n }\n </div>\n</ng-template>\n", styles: ["@charset \"UTF-8\";:host{display:block;font-family:var(--mk-font-sans);color:var(--mk-text)}.mk-media-gallery__grid{display:grid;grid-template-columns:repeat(var(--_cols, 4),1fr);gap:var(--mk-space-3)}.mk-media-gallery__tile{position:relative;display:flex;flex-direction:column;gap:var(--mk-space-1);min-width:0;border-radius:var(--mk-radius-md)}.mk-media-gallery__tile:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-media-gallery__media{position:relative;aspect-ratio:1/1;overflow:hidden;background-color:var(--mk-surface-2);border-radius:var(--mk-radius-md)}.mk-media-gallery__tile--selected .mk-media-gallery__media{outline:var(--mk-border-width-strong) solid var(--mk-primary);outline-offset:var(--mk-focus-ring-offset)}.mk-media-gallery__preview{position:absolute;inset:0;display:block;width:100%;height:100%;padding:0;margin:0;cursor:pointer;background:none;border:none;border-radius:inherit}.mk-media-gallery__preview:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:calc(-1 * var(--mk-focus-ring-width) - var(--mk-focus-ring-offset))}.mk-media-gallery__img{display:block;width:100%;height:100%;object-fit:cover;transition:transform var(--mk-duration-normal) var(--mk-ease-standard)}.mk-media-gallery__preview:hover .mk-media-gallery__img{transform:scale(1.04)}.mk-media-gallery__check{position:absolute;top:var(--mk-space-2);left:var(--mk-space-2);display:inline-flex;width:1.25rem;height:1.25rem}.mk-media-gallery__check-input{position:absolute;inset:calc(-1 * var(--mk-space-2));width:auto;height:auto;margin:0;cursor:pointer;opacity:0}.mk-media-gallery__check-box{display:inline-flex;align-items:center;justify-content:center;width:100%;height:100%;pointer-events:none;background-color:var(--mk-surface);border:var(--mk-border-width-strong) solid var(--mk-border-strong);border-radius:var(--mk-radius-sm);box-shadow:var(--mk-shadow-sm);transition:background-color var(--mk-duration-fast) var(--mk-ease-standard),border-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-media-gallery__check-mark{width:30%;height:55%;border-right:2px solid var(--mk-primary-contrast);border-bottom:2px solid var(--mk-primary-contrast);opacity:0;transform:rotate(45deg) translateY(-8%)}.mk-media-gallery__check-input:hover+.mk-media-gallery__check-box{border-color:var(--mk-primary)}.mk-media-gallery__check-input:checked+.mk-media-gallery__check-box{background-color:var(--mk-primary);border-color:var(--mk-primary)}.mk-media-gallery__check-input:checked+.mk-media-gallery__check-box .mk-media-gallery__check-mark{opacity:1}.mk-media-gallery__check-input:focus-visible+.mk-media-gallery__check-box{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-media-gallery__actions{position:absolute;top:var(--mk-space-2);right:var(--mk-space-2);display:flex;gap:var(--mk-space-1);align-items:center;padding:var(--mk-space-1);background-color:color-mix(in srgb,var(--mk-surface) 85%,transparent);border-radius:var(--mk-radius-sm);box-shadow:var(--mk-shadow-sm);opacity:0;transition:opacity var(--mk-duration-fast) var(--mk-ease-standard)}.mk-media-gallery__tile:hover .mk-media-gallery__actions,.mk-media-gallery__tile:focus-within .mk-media-gallery__actions{opacity:1}@media(hover:none){.mk-media-gallery__actions{opacity:1}}.mk-media-gallery__caption{display:flex;flex-direction:column;min-width:0;padding:0 var(--mk-space-1) var(--mk-space-1)}.mk-media-gallery__name{overflow:hidden;font-size:var(--mk-font-size-sm);font-weight:var(--mk-font-weight-medium);text-overflow:ellipsis;white-space:nowrap}.mk-media-gallery__meta{overflow:hidden;font-size:var(--mk-font-size-xs);color:var(--mk-text-muted);text-overflow:ellipsis;white-space:nowrap}.mk-media-gallery__empty{display:flex;align-items:center;justify-content:center;padding:var(--mk-space-10) var(--mk-space-4);font-size:var(--mk-font-size-sm);color:var(--mk-text-muted);text-align:center;background-color:var(--mk-surface-2);border:var(--mk-border-width) dashed var(--mk-border);border-radius:var(--mk-radius-lg)}@media(prefers-reduced-motion:reduce){.mk-media-gallery__img,.mk-media-gallery__actions,.mk-media-gallery__check-box{transition:none}.mk-media-gallery__preview:hover .mk-media-gallery__img{transform:none}}\n"], dependencies: [{ kind: "component", type: MkDropList, selector: "[mkDropList]", inputs: ["mkDropListData", "mkDropListId", "mkDropListConnectedTo", "mkDropListLabel", "mkDropListOrientation", "mkDropListDisabled"], outputs: ["mkDropListDropped"], exportAs: ["mkDropList"] }, { kind: "component", type: MkDrag, selector: "[mkDrag]", inputs: ["mkDragData", "mkDragDisabled", "mkDragTouchDelay"], exportAs: ["mkDrag"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
876
|
+
}
|
|
877
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkMediaGallery, decorators: [{
|
|
878
|
+
type: Component,
|
|
879
|
+
args: [{ selector: 'mk-media-gallery', exportAs: 'mkMediaGallery', changeDetection: ChangeDetectionStrategy.OnPush, imports: [MkDropList, MkDrag, NgTemplateOutlet], host: {
|
|
880
|
+
class: 'mk-media-gallery',
|
|
881
|
+
role: 'group',
|
|
882
|
+
'[attr.aria-label]': 'ariaLabel()',
|
|
883
|
+
}, template: "@if (items().length === 0) {\n <div class=\"mk-media-gallery__empty\">{{ i18n.noData }}</div>\n} @else if (reorderable()) {\n <div\n mkDropList\n class=\"mk-media-gallery__grid\"\n [style.--_cols]=\"columns()\"\n [mkDropListData]=\"items()\"\n [mkDropListLabel]=\"ariaLabel()\"\n mkDropListOrientation=\"horizontal\"\n (mkDropListDropped)=\"onDrop($event)\"\n >\n @for (item of items(); track item.id) {\n <div\n mkDrag\n class=\"mk-media-gallery__tile\"\n [class.mk-media-gallery__tile--selected]=\"isSelected(item.id)\"\n [mkDragData]=\"item\"\n >\n <ng-container\n [ngTemplateOutlet]=\"tile\"\n [ngTemplateOutletContext]=\"{ $implicit: item }\"\n />\n </div>\n }\n </div>\n} @else {\n <div class=\"mk-media-gallery__grid\" [style.--_cols]=\"columns()\">\n @for (item of items(); track item.id) {\n <div\n class=\"mk-media-gallery__tile\"\n [class.mk-media-gallery__tile--selected]=\"isSelected(item.id)\"\n >\n <ng-container\n [ngTemplateOutlet]=\"tile\"\n [ngTemplateOutletContext]=\"{ $implicit: item }\"\n />\n </div>\n }\n </div>\n}\n\n<ng-template #tile let-item>\n <div class=\"mk-media-gallery__media\">\n <button\n type=\"button\"\n class=\"mk-media-gallery__preview\"\n [attr.aria-label]=\"i18n.viewImage(item.alt ?? item.name)\"\n (click)=\"onPreviewClick(item)\"\n (keydown)=\"stopEvent($event)\"\n >\n <img\n class=\"mk-media-gallery__img\"\n [src]=\"item.thumb ?? item.src\"\n [alt]=\"item.alt ?? ''\"\n loading=\"lazy\"\n draggable=\"false\"\n />\n </button>\n\n @if (selectable()) {\n <span\n class=\"mk-media-gallery__check\"\n (keydown)=\"stopEvent($event)\"\n (pointerdown)=\"stopEvent($event)\"\n >\n <input\n type=\"checkbox\"\n class=\"mk-media-gallery__check-input\"\n [checked]=\"isSelected(item.id)\"\n [attr.aria-label]=\"i18n.selectRow(item.name)\"\n (change)=\"onCheckboxChange(item, $event)\"\n />\n <span class=\"mk-media-gallery__check-box\" aria-hidden=\"true\">\n <span class=\"mk-media-gallery__check-mark\"></span>\n </span>\n </span>\n }\n\n @if (actions(); as actionsDir) {\n <div\n class=\"mk-media-gallery__actions\"\n (keydown)=\"stopEvent($event)\"\n (pointerdown)=\"stopEvent($event)\"\n >\n <ng-container\n [ngTemplateOutlet]=\"actionsDir.template\"\n [ngTemplateOutletContext]=\"{ $implicit: item }\"\n />\n </div>\n }\n </div>\n\n <div class=\"mk-media-gallery__caption\">\n <span class=\"mk-media-gallery__name\">{{ item.name }}</span>\n @if (item.meta) {\n <span class=\"mk-media-gallery__meta\">{{ item.meta }}</span>\n }\n </div>\n</ng-template>\n", styles: ["@charset \"UTF-8\";:host{display:block;font-family:var(--mk-font-sans);color:var(--mk-text)}.mk-media-gallery__grid{display:grid;grid-template-columns:repeat(var(--_cols, 4),1fr);gap:var(--mk-space-3)}.mk-media-gallery__tile{position:relative;display:flex;flex-direction:column;gap:var(--mk-space-1);min-width:0;border-radius:var(--mk-radius-md)}.mk-media-gallery__tile:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-media-gallery__media{position:relative;aspect-ratio:1/1;overflow:hidden;background-color:var(--mk-surface-2);border-radius:var(--mk-radius-md)}.mk-media-gallery__tile--selected .mk-media-gallery__media{outline:var(--mk-border-width-strong) solid var(--mk-primary);outline-offset:var(--mk-focus-ring-offset)}.mk-media-gallery__preview{position:absolute;inset:0;display:block;width:100%;height:100%;padding:0;margin:0;cursor:pointer;background:none;border:none;border-radius:inherit}.mk-media-gallery__preview:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:calc(-1 * var(--mk-focus-ring-width) - var(--mk-focus-ring-offset))}.mk-media-gallery__img{display:block;width:100%;height:100%;object-fit:cover;transition:transform var(--mk-duration-normal) var(--mk-ease-standard)}.mk-media-gallery__preview:hover .mk-media-gallery__img{transform:scale(1.04)}.mk-media-gallery__check{position:absolute;top:var(--mk-space-2);left:var(--mk-space-2);display:inline-flex;width:1.25rem;height:1.25rem}.mk-media-gallery__check-input{position:absolute;inset:calc(-1 * var(--mk-space-2));width:auto;height:auto;margin:0;cursor:pointer;opacity:0}.mk-media-gallery__check-box{display:inline-flex;align-items:center;justify-content:center;width:100%;height:100%;pointer-events:none;background-color:var(--mk-surface);border:var(--mk-border-width-strong) solid var(--mk-border-strong);border-radius:var(--mk-radius-sm);box-shadow:var(--mk-shadow-sm);transition:background-color var(--mk-duration-fast) var(--mk-ease-standard),border-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-media-gallery__check-mark{width:30%;height:55%;border-right:2px solid var(--mk-primary-contrast);border-bottom:2px solid var(--mk-primary-contrast);opacity:0;transform:rotate(45deg) translateY(-8%)}.mk-media-gallery__check-input:hover+.mk-media-gallery__check-box{border-color:var(--mk-primary)}.mk-media-gallery__check-input:checked+.mk-media-gallery__check-box{background-color:var(--mk-primary);border-color:var(--mk-primary)}.mk-media-gallery__check-input:checked+.mk-media-gallery__check-box .mk-media-gallery__check-mark{opacity:1}.mk-media-gallery__check-input:focus-visible+.mk-media-gallery__check-box{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-media-gallery__actions{position:absolute;top:var(--mk-space-2);right:var(--mk-space-2);display:flex;gap:var(--mk-space-1);align-items:center;padding:var(--mk-space-1);background-color:color-mix(in srgb,var(--mk-surface) 85%,transparent);border-radius:var(--mk-radius-sm);box-shadow:var(--mk-shadow-sm);opacity:0;transition:opacity var(--mk-duration-fast) var(--mk-ease-standard)}.mk-media-gallery__tile:hover .mk-media-gallery__actions,.mk-media-gallery__tile:focus-within .mk-media-gallery__actions{opacity:1}@media(hover:none){.mk-media-gallery__actions{opacity:1}}.mk-media-gallery__caption{display:flex;flex-direction:column;min-width:0;padding:0 var(--mk-space-1) var(--mk-space-1)}.mk-media-gallery__name{overflow:hidden;font-size:var(--mk-font-size-sm);font-weight:var(--mk-font-weight-medium);text-overflow:ellipsis;white-space:nowrap}.mk-media-gallery__meta{overflow:hidden;font-size:var(--mk-font-size-xs);color:var(--mk-text-muted);text-overflow:ellipsis;white-space:nowrap}.mk-media-gallery__empty{display:flex;align-items:center;justify-content:center;padding:var(--mk-space-10) var(--mk-space-4);font-size:var(--mk-font-size-sm);color:var(--mk-text-muted);text-align:center;background-color:var(--mk-surface-2);border:var(--mk-border-width) dashed var(--mk-border);border-radius:var(--mk-radius-lg)}@media(prefers-reduced-motion:reduce){.mk-media-gallery__img,.mk-media-gallery__actions,.mk-media-gallery__check-box{transition:none}.mk-media-gallery__preview:hover .mk-media-gallery__img{transform:none}}\n"] }]
|
|
884
|
+
}], propDecorators: { items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }, { type: i0.Output, args: ["itemsChange"] }], selection: [{ type: i0.Input, args: [{ isSignal: true, alias: "selection", required: false }] }, { type: i0.Output, args: ["selectionChange"] }], selectable: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectable", required: false }] }], reorderable: [{ type: i0.Input, args: [{ isSignal: true, alias: "reorderable", required: false }] }], columns: [{ type: i0.Input, args: [{ isSignal: true, alias: "columns", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], itemClick: [{ type: i0.Output, args: ["itemClick"] }], reordered: [{ type: i0.Output, args: ["reordered"] }], actions: [{ type: i0.ContentChild, args: [i0.forwardRef(() => MkMediaActions), { isSignal: true }] }] } });
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* MEDIA components group barrel for @mk-kit/ui.
|
|
888
|
+
* Images, galleries, lightbox, cropping and media management.
|
|
889
|
+
*/
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Generated bundle index. Do not edit.
|
|
893
|
+
*/
|
|
894
|
+
|
|
895
|
+
export { MkImage, MkImageCropper, MkImageGallery, MkLightbox, MkLightboxService, MkMediaActions, MkMediaGallery, mkClampPan, mkCoverScale, mkCropRect };
|
|
896
|
+
//# sourceMappingURL=mk-kit-ui-media.mjs.map
|