@dominikcz/greg 0.9.29 → 0.9.32

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.
@@ -147,6 +147,57 @@ describe('rehypeTocPlaceholder — [[TOC]]', () => {
147
147
  });
148
148
  });
149
149
 
150
+ describe('remarkFlexRow — [[ line\nline ]]', () => {
151
+ it('renders top-level flex container with wrapped items', async () => {
152
+ const html = await processMarkdown('[[\nOne\nTwo\nThree\n]]');
153
+ expect(html).toContain('class="markdown-flex-row"');
154
+ expect((html.match(/class="markdown-flex-item"/g) ?? []).length).toBe(3);
155
+ expect(html).toContain('>One<');
156
+ expect(html).toContain('>Two<');
157
+ expect(html).toContain('>Three<');
158
+ });
159
+
160
+ it('supports markdown content inside line items', async () => {
161
+ const html = await processMarkdown('[[\n**Bold**\n[Link](https://example.com)\n]]');
162
+ expect(html).toContain('<strong>Bold</strong>');
163
+ expect(html).toContain('<a href="https://example.com">Link</a>');
164
+ });
165
+
166
+ it('accepts newline-delimited items without commas', async () => {
167
+ const html = await processMarkdown('[[\nOne\nTwo\n]]');
168
+ expect(html).toContain('class="markdown-flex-row"');
169
+ expect((html.match(/class="markdown-flex-item"/g) ?? []).length).toBe(2);
170
+ expect(html).toContain('>One<');
171
+ expect(html).toContain('>Two<');
172
+ });
173
+
174
+ it('works inside list items', async () => {
175
+ const html = await processMarkdown('- [[\n A\n B\n ]]');
176
+ expect(html).toContain('class="markdown-flex-row"');
177
+ expect((html.match(/class="markdown-flex-item"/g) ?? []).length).toBe(2);
178
+ });
179
+
180
+ it('supports image markdown inside items', async () => {
181
+ const html = await processMarkdown('[[\n![one](/one.png)\n![two](/two.png)\n]]');
182
+ expect(html).toContain('src="/one.png"');
183
+ expect(html).toContain('src="/two.png"');
184
+ });
185
+
186
+ it('supports multiline syntax with optional trailing commas', async () => {
187
+ const html = await processMarkdown('[[\n![one](/one.png),\n![two](/two.png)\n]]');
188
+ expect(html).toContain('class="markdown-flex-row"');
189
+ expect((html.match(/class="markdown-flex-item"/g) ?? []).length).toBe(2);
190
+ expect(html).toContain('src="/one.png"');
191
+ expect(html).toContain('src="/two.png"');
192
+ });
193
+
194
+ it('does not parse one-line comma-separated syntax', async () => {
195
+ const html = await processMarkdown('[[One, Two]]');
196
+ expect(html).not.toContain('class="markdown-flex-row"');
197
+ expect(html).toContain('[[One, Two]]');
198
+ });
199
+ });
200
+
150
201
  // ─── Custom Containers ─────────────────────────────────────────────────────────
151
202
 
152
203
  describe('remarkContainers — ::: info', () => {
@@ -12,11 +12,13 @@ import rehypeCodeTitle from "./rehypeCodeTitle.js";
12
12
  import { remarkCodeMeta } from "./remarkCodeMeta.js";
13
13
  import { remarkCustomAnchors } from "./remarkCustomAnchors.js";
14
14
  import { remarkInlineAttrs } from "./remarkInlineAttrs.js";
15
+ import { remarkFlexRow } from "./remarkFlexRow.js";
15
16
  import { remarkImportsBrowser } from "./remarkImportsBrowser.js";
16
17
 
17
18
  import Badge from "../components/Badge.svelte";
18
19
  import Button from "../components/Button.svelte";
19
20
  import Image from "../components/Image.svelte";
21
+ import MarkdownImagePreview from "../components/MarkdownImagePreview.svelte";
20
22
  import Link from "../components/Link.svelte";
21
23
  import CodeGroup from "../components/CodeGroup.svelte";
22
24
  import Hero from "../components/Hero.svelte";
@@ -170,6 +172,10 @@ export const COMPONENT_REGISTRY: Record<string, ComponentHydrationEntry> = {
170
172
  component: Image,
171
173
  buildProps: buildPropsFromAttributes,
172
174
  },
175
+ markdownimage: {
176
+ component: MarkdownImagePreview,
177
+ buildProps: buildPropsFromAttributes,
178
+ },
173
179
  link: {
174
180
  component: Link,
175
181
  buildProps: buildPropsFromAttributes,
@@ -252,6 +258,7 @@ export function getRemarkPluginEntries(
252
258
  return [
253
259
  { name: "remark-parse", plugin: remarkParse },
254
260
  { name: "remark-gfm", plugin: remarkGfm },
261
+ { name: "remark-flex-row", plugin: remarkFlexRow },
255
262
  { name: "remark-inline-attrs", plugin: remarkInlineAttrs },
256
263
  {
257
264
  name: "remark-imports-browser",
@@ -278,6 +285,7 @@ export function getRemarkPluginEntries(
278
285
  export function getRehypePluginEntries(deps: {
279
286
  rehypeStepsWrapper: any;
280
287
  rehypeMermaid: any;
288
+ rehypeMarkdownImageThumbs: any;
281
289
  rehypeShiki: any;
282
290
  }): PluginEntry[] {
283
291
  return [
@@ -289,6 +297,10 @@ export function getRehypePluginEntries(deps: {
289
297
  },
290
298
  { name: "rehype-steps-wrapper", plugin: deps.rehypeStepsWrapper },
291
299
  { name: "rehype-mermaid", plugin: deps.rehypeMermaid },
300
+ {
301
+ name: "rehype-markdown-image-thumbs",
302
+ plugin: deps.rehypeMarkdownImageThumbs,
303
+ },
292
304
  { name: "rehype-shiki", plugin: deps.rehypeShiki },
293
305
  { name: "rehype-containers", plugin: rehypeContainers },
294
306
  { name: "rehype-code-group", plugin: rehypeCodeGroup },
@@ -0,0 +1,86 @@
1
+ import { unified } from 'unified';
2
+ import remarkParse from 'remark-parse';
3
+ import remarkGfm from 'remark-gfm';
4
+ import { visit } from 'unist-util-visit';
5
+
6
+ function parseFlexPattern(value) {
7
+ const raw = String(value ?? '').trim();
8
+ if (!raw.startsWith('[[') || !raw.endsWith(']]')) return null;
9
+
10
+ const inner = raw.slice(2, -2).trim();
11
+ if (!inner) return [];
12
+ if (/^toc$/i.test(inner)) return null;
13
+
14
+ return parseLineItems(inner);
15
+ }
16
+
17
+ function parseLineItems(inner) {
18
+ if (!inner.includes('\n')) return null;
19
+
20
+ const parts = inner
21
+ .split('\n')
22
+ .map((line) => line.trim())
23
+ .filter(Boolean)
24
+ .map((line) => line.replace(/,$/, '').trim())
25
+ .filter(Boolean);
26
+
27
+ return parts.length ? parts : null;
28
+ }
29
+
30
+ function parseMarkdownFragment(fragment, parser) {
31
+ const tree = parser.parse(fragment);
32
+ return Array.isArray(tree?.children) ? tree.children : [];
33
+ }
34
+
35
+ function createFlexItemNode(children) {
36
+ return {
37
+ type: 'flexRowItem',
38
+ data: {
39
+ hName: 'div',
40
+ hProperties: { className: ['markdown-flex-item'] },
41
+ },
42
+ children,
43
+ };
44
+ }
45
+
46
+ function createFlexRowNode(items, parser) {
47
+ return {
48
+ type: 'flexRowBlock',
49
+ data: {
50
+ hName: 'div',
51
+ hProperties: { className: ['markdown-flex-row'] },
52
+ },
53
+ children: items.map((item) =>
54
+ createFlexItemNode(parseMarkdownFragment(item, parser)),
55
+ ),
56
+ };
57
+ }
58
+
59
+ export function remarkFlexRow() {
60
+ const parser = unified().use(remarkParse).use(remarkGfm);
61
+
62
+ return (tree, file) => {
63
+ const source = typeof file?.value === 'string' ? file.value : '';
64
+
65
+ visit(tree, 'paragraph', (node, index, parent) => {
66
+ if (!parent || index == null) return;
67
+
68
+ let raw = '';
69
+ const start = node?.position?.start?.offset;
70
+ const end = node?.position?.end?.offset;
71
+
72
+ if (typeof start === 'number' && typeof end === 'number' && end >= start) {
73
+ raw = source.slice(start, end);
74
+ } else {
75
+ raw = (node.children ?? [])
76
+ .map((child) => child?.value ?? '')
77
+ .join('');
78
+ }
79
+
80
+ const parsedItems = parseFlexPattern(raw);
81
+ if (!parsedItems || parsedItems.length === 0) return;
82
+
83
+ parent.children[index] = createFlexRowNode(parsedItems, parser);
84
+ });
85
+ };
86
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * vitePluginCopyDocs
3
3
  *
4
- * Copies docs/**\/*.md (and optional extra static dirs) to the build output
4
+ * Copies docs/** files (and optional extra static dirs) to the build output
5
5
  * directory as-is, so they can be fetched at runtime by the browser without
6
6
  * being compiled by mdsvex/rollup.
7
7
  *
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * The `staticDirs` option (default: ['snippets']) lists additional project-root
12
12
  * directories whose files should also be served/copied verbatim. This is
13
- * needed for `<<< @”‹/snippets/file.js` style snippet includes.
13
+ * needed for snippet include syntax that references files from static dirs.
14
14
  */
15
15
 
16
16
  import fs from 'node:fs';
@@ -25,11 +25,21 @@ export function vitePluginCopyDocs({ docsDir = 'docs', srcDir = '/docs', staticD
25
25
  let outDir = 'dist';
26
26
  let viteBase = '/';
27
27
 
28
+ function isTraversableDirectory(entry, fullPath) {
29
+ if (entry.isDirectory()) return true;
30
+ if (!entry.isSymbolicLink()) return false;
31
+ try {
32
+ return fs.statSync(fullPath).isDirectory();
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
28
38
  function* walkAll(dir) {
29
39
  if (!fs.existsSync(dir)) return;
30
40
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
31
41
  const full = path.join(dir, entry.name);
32
- if (entry.isDirectory()) {
42
+ if (isTraversableDirectory(entry, full)) {
33
43
  yield* walkAll(full);
34
44
  } else if (entry.isFile()) {
35
45
  yield full;
@@ -37,16 +47,29 @@ export function vitePluginCopyDocs({ docsDir = 'docs', srcDir = '/docs', staticD
37
47
  }
38
48
  }
39
49
 
40
- function* walkMd(dir) {
41
- for (const full of walkAll(dir)) {
42
- if (full.endsWith('.md')) yield full;
43
- }
44
- }
45
-
46
50
  function toPosix(value) {
47
51
  return String(value).replace(/\\/g, '/');
48
52
  }
49
53
 
54
+ function getContentType(filePath) {
55
+ const ext = path.extname(filePath).toLowerCase();
56
+ switch (ext) {
57
+ case '.md': return 'text/plain; charset=utf-8';
58
+ case '.txt': return 'text/plain; charset=utf-8';
59
+ case '.json': return 'application/json; charset=utf-8';
60
+ case '.js': return 'text/javascript; charset=utf-8';
61
+ case '.css': return 'text/css; charset=utf-8';
62
+ case '.svg': return 'image/svg+xml';
63
+ case '.png': return 'image/png';
64
+ case '.jpg':
65
+ case '.jpeg': return 'image/jpeg';
66
+ case '.webp': return 'image/webp';
67
+ case '.gif': return 'image/gif';
68
+ case '.pdf': return 'application/pdf';
69
+ default: return 'application/octet-stream';
70
+ }
71
+ }
72
+
50
73
  function resolveRootPrefix() {
51
74
  const cleaned = trimSlashes(srcDir);
52
75
  return cleaned ? '/' + cleaned : '';
@@ -62,7 +85,7 @@ export function vitePluginCopyDocs({ docsDir = 'docs', srcDir = '/docs', staticD
62
85
  },
63
86
 
64
87
  /**
65
- * In dev mode: serve .md files and staticDirs files as plain text.
88
+ * In dev mode: serve docs files and staticDirs files.
66
89
  * Vite doesn't auto-serve project files outside public/ as raw assets.
67
90
  */
68
91
  configureServer(server) {
@@ -86,17 +109,17 @@ export function vitePluginCopyDocs({ docsDir = 'docs', srcDir = '/docs', staticD
86
109
  ? '/' + rawUrl.slice(base.length).replace(/^\/+/, '')
87
110
  : rawUrl;
88
111
 
89
- // Docs markdown files
90
- const isDocsMarkdown = rootPrefix
91
- ? (url === rootPrefix || url.startsWith(rootPrefix + '/')) && url.endsWith('.md')
92
- : url.startsWith('/') && url.endsWith('.md');
93
- if (isDocsMarkdown) {
112
+ // Docs files (markdown + local assets like images/pdf)
113
+ const isDocsPath = rootPrefix
114
+ ? (url === rootPrefix || url.startsWith(rootPrefix + '/'))
115
+ : url.startsWith('/');
116
+ if (isDocsPath) {
94
117
  const rel = url.slice(rootPrefix.length).replace(/^\//, '');
95
118
  for (const dir of (Array.isArray(docsDir) ? docsDir : [docsDir])) {
96
119
  const filePath = path.resolve(root, dir, rel);
97
- if (fs.existsSync(filePath)) {
98
- res.setHeader('Content-Type', 'text/plain; charset=utf-8');
99
- res.end(fs.readFileSync(filePath, 'utf8'));
120
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
121
+ res.setHeader('Content-Type', getContentType(filePath));
122
+ res.end(fs.readFileSync(filePath));
100
123
  return;
101
124
  }
102
125
  }
@@ -118,15 +141,15 @@ export function vitePluginCopyDocs({ docsDir = 'docs', srcDir = '/docs', staticD
118
141
  });
119
142
  },
120
143
 
121
- /** After bundle is written, copy .md files and staticDirs verbatim. */
144
+ /** After bundle is written, copy docs files and staticDirs verbatim. */
122
145
  writeBundle() {
123
146
  let count = 0;
124
147
  const rootPrefix = trimSlashes(resolveRootPrefix());
125
148
 
126
- // Copy markdown docs from all source dirs
149
+ // Copy docs files from all source dirs
127
150
  for (const dir of (Array.isArray(docsDir) ? docsDir : [docsDir])) {
128
151
  const docsRoot = path.resolve(root, dir);
129
- for (const srcFile of walkMd(docsRoot)) {
152
+ for (const srcFile of walkAll(docsRoot)) {
130
153
  const rel = toPosix(path.relative(docsRoot, srcFile));
131
154
  const destFile = path.join(outDir, rootPrefix, rel);
132
155
  fs.mkdirSync(path.dirname(destFile), { recursive: true });
@@ -44,6 +44,16 @@ export function vitePluginFrontmatter({ docsDir = 'docs', srcDir = '/docs' } = {
44
44
  return cleaned ? `/${cleaned}` : '/';
45
45
  }
46
46
 
47
+ function isTraversableDirectory(entry, fullPath) {
48
+ if (entry.isDirectory()) return true;
49
+ if (!entry.isSymbolicLink()) return false;
50
+ try {
51
+ return fs.statSync(fullPath).isDirectory();
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
47
57
  /** Collect all .md paths and return the virtual module source. */
48
58
  function buildModule() {
49
59
  const absDirs = (Array.isArray(docsDir) ? docsDir : [docsDir]).map(d => path.resolve(root, d));
@@ -56,7 +66,7 @@ export function vitePluginFrontmatter({ docsDir = 'docs', srcDir = '/docs' } = {
56
66
  catch { return; }
57
67
  for (const item of items) {
58
68
  const full = path.join(dir, item.name);
59
- if (item.isDirectory()) {
69
+ if (isTraversableDirectory(item, full)) {
60
70
  walk(full, baseDir);
61
71
  } else if (item.isFile() && item.name.endsWith('.md') && !item.name.startsWith('__')) {
62
72
  const rel = path.relative(baseDir, full).replace(/\\/g, '/');
@@ -0,0 +1,345 @@
1
+ <script lang="ts">
2
+ type Props = {
3
+ src?: string;
4
+ alt?: string;
5
+ title?: string;
6
+ width?: number | string;
7
+ height?: number | string;
8
+ };
9
+
10
+ let { src = "", alt = "", title = "", width, height }: Props = $props();
11
+
12
+ let isOpen = $state(false);
13
+ let zoom = $state(1);
14
+ let isDragging = $state(false);
15
+ let dragStartX = $state(0);
16
+ let dragStartY = $state(0);
17
+ let dragStartScrollLeft = $state(0);
18
+ let dragStartScrollTop = $state(0);
19
+ let viewportEl = $state<HTMLDivElement | null>(null);
20
+
21
+ const MIN_ZOOM = 1;
22
+ const MAX_ZOOM = 6;
23
+ const ZOOM_STEP = 0.25;
24
+
25
+ const caption = $derived((String(title || "").trim() || String(alt || "").trim()));
26
+ const zoomLabel = $derived(`${Math.round(zoom * 100)}%`);
27
+ const isPannable = $derived(zoom > 1);
28
+
29
+ const previewImageStyle = $derived.by(() => {
30
+ if (zoom <= 1) return undefined;
31
+ return `width: ${Math.round(zoom * 100)}%; max-width: none; max-height: none;`;
32
+ });
33
+
34
+ function clampZoom(value: number) {
35
+ return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value));
36
+ }
37
+
38
+ function setZoom(value: number) {
39
+ zoom = clampZoom(value);
40
+ }
41
+
42
+ function zoomIn() {
43
+ setZoom(zoom + ZOOM_STEP);
44
+ }
45
+
46
+ function zoomOut() {
47
+ setZoom(zoom - ZOOM_STEP);
48
+ }
49
+
50
+ function resetZoom() {
51
+ setZoom(1);
52
+ isDragging = false;
53
+ }
54
+
55
+ function beginPan(event: MouseEvent) {
56
+ if (!isPannable || !viewportEl) return;
57
+ isDragging = true;
58
+ dragStartX = event.clientX;
59
+ dragStartY = event.clientY;
60
+ dragStartScrollLeft = viewportEl.scrollLeft;
61
+ dragStartScrollTop = viewportEl.scrollTop;
62
+ event.preventDefault();
63
+ }
64
+
65
+ function continuePan(event: MouseEvent) {
66
+ if (!isDragging || !viewportEl) return;
67
+ const deltaX = event.clientX - dragStartX;
68
+ const deltaY = event.clientY - dragStartY;
69
+ viewportEl.scrollLeft = dragStartScrollLeft - deltaX;
70
+ viewportEl.scrollTop = dragStartScrollTop - deltaY;
71
+ event.preventDefault();
72
+ }
73
+
74
+ function endPan() {
75
+ isDragging = false;
76
+ }
77
+
78
+ function openPreview() {
79
+ if (!src) return;
80
+ isOpen = true;
81
+ resetZoom();
82
+ }
83
+
84
+ function closePreview() {
85
+ isOpen = false;
86
+ resetZoom();
87
+ }
88
+
89
+ function handleKeydown(event: KeyboardEvent) {
90
+ if (event.key === "Escape" && isOpen) {
91
+ closePreview();
92
+ }
93
+ }
94
+
95
+ function handleWheel(event: WheelEvent) {
96
+ if (!isOpen) return;
97
+ event.preventDefault();
98
+ if (event.deltaY < 0) {
99
+ zoomIn();
100
+ return;
101
+ }
102
+ zoomOut();
103
+ }
104
+
105
+ $effect(() => {
106
+ if (typeof window === "undefined" || !isOpen) return;
107
+ window.addEventListener("keydown", handleKeydown);
108
+ return () => {
109
+ window.removeEventListener("keydown", handleKeydown);
110
+ };
111
+ });
112
+
113
+ $effect(() => {
114
+ if (typeof window === "undefined" || !isDragging) return;
115
+ window.addEventListener("mousemove", continuePan);
116
+ window.addEventListener("mouseup", endPan);
117
+ return () => {
118
+ window.removeEventListener("mousemove", continuePan);
119
+ window.removeEventListener("mouseup", endPan);
120
+ };
121
+ });
122
+ </script>
123
+
124
+ {#if src}
125
+ <figure class="markdown-image-figure">
126
+ <button
127
+ type="button"
128
+ class="markdown-image-thumb"
129
+ onclick={openPreview}
130
+ aria-label={caption ? `Open image preview: ${caption}` : "Open image preview"}
131
+ >
132
+ <img src={src} alt={alt} title={title || undefined} width={width} height={height} />
133
+ </button>
134
+ {#if caption}
135
+ <figcaption class="markdown-image-caption">{caption}</figcaption>
136
+ {/if}
137
+ </figure>
138
+ {/if}
139
+
140
+ {#if isOpen}
141
+ <div class="markdown-image-preview" role="dialog" aria-modal="true" aria-label="Image preview">
142
+ <button
143
+ type="button"
144
+ class="markdown-image-preview__backdrop"
145
+ aria-label="Close image preview"
146
+ onclick={closePreview}
147
+ ></button>
148
+ <div class="markdown-image-preview__content">
149
+ <div class="markdown-image-preview__toolbar" role="group" aria-label="Image zoom controls">
150
+ <button
151
+ type="button"
152
+ class="markdown-image-preview__zoom-btn"
153
+ aria-label="Zoom out"
154
+ onclick={zoomOut}
155
+ disabled={zoom <= MIN_ZOOM}
156
+ >
157
+
158
+ </button>
159
+ <button
160
+ type="button"
161
+ class="markdown-image-preview__zoom-btn markdown-image-preview__zoom-label"
162
+ aria-label="Reset zoom"
163
+ onclick={resetZoom}
164
+ >
165
+ {zoomLabel}
166
+ </button>
167
+ <button
168
+ type="button"
169
+ class="markdown-image-preview__zoom-btn"
170
+ aria-label="Zoom in"
171
+ onclick={zoomIn}
172
+ disabled={zoom >= MAX_ZOOM}
173
+ >
174
+ +
175
+ </button>
176
+ </div>
177
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
178
+ <div
179
+ class="markdown-image-preview__viewport"
180
+ class:is-pannable={isPannable}
181
+ class:is-dragging={isDragging}
182
+ bind:this={viewportEl}
183
+ onwheel={handleWheel}
184
+ onmousedown={beginPan}
185
+ >
186
+ <img
187
+ src={src}
188
+ alt={alt || caption || "Preview image"}
189
+ class="markdown-image-preview__image"
190
+ style={previewImageStyle}
191
+ />
192
+ </div>
193
+ <button
194
+ type="button"
195
+ class="markdown-image-preview__close"
196
+ aria-label="Close image preview"
197
+ onclick={closePreview}
198
+ >
199
+ ×
200
+ </button>
201
+ {#if caption}
202
+ <p class="markdown-image-preview__caption">{caption}</p>
203
+ {/if}
204
+ </div>
205
+ </div>
206
+ {/if}
207
+
208
+ <style>
209
+ .markdown-image-figure {
210
+ margin: 1rem 0;
211
+ max-width: 100%;
212
+ }
213
+
214
+ .markdown-image-thumb {
215
+ display: block;
216
+ padding: 0;
217
+ border: 0;
218
+ background: transparent;
219
+ cursor: zoom-in;
220
+ max-width: 100%;
221
+ }
222
+
223
+ .markdown-image-thumb img {
224
+ display: block;
225
+ max-width: 100%;
226
+ max-height: 220px;
227
+ width: auto;
228
+ border-radius: 8px;
229
+ border: 1px solid var(--greg-border-color);
230
+ transition: transform 0.14s ease, box-shadow 0.14s ease;
231
+ }
232
+
233
+ .markdown-image-thumb:hover img {
234
+ transform: translateY(-1px);
235
+ box-shadow: 0 8px 18px color-mix(in srgb, var(--greg-color) 16%, transparent);
236
+ }
237
+
238
+ .markdown-image-caption {
239
+ margin-top: 0.45rem;
240
+ font-size: 0.9rem;
241
+ color: var(--fgColor-muted);
242
+ }
243
+
244
+ .markdown-image-preview {
245
+ position: fixed;
246
+ inset: 0;
247
+ z-index: 1200;
248
+ display: grid;
249
+ place-items: center;
250
+ padding: 1.25rem;
251
+ }
252
+
253
+ .markdown-image-preview__backdrop {
254
+ position: absolute;
255
+ inset: 0;
256
+ border: none;
257
+ background: color-mix(in srgb, #000 74%, transparent);
258
+ cursor: zoom-out;
259
+ }
260
+
261
+ .markdown-image-preview__content {
262
+ position: relative;
263
+ max-width: min(92vw, 1500px);
264
+ max-height: 92vh;
265
+ z-index: 1;
266
+ display: grid;
267
+ gap: 0.5rem;
268
+ }
269
+
270
+ .markdown-image-preview__toolbar {
271
+ display: inline-flex;
272
+ gap: 0.4rem;
273
+ justify-self: center;
274
+ }
275
+
276
+ .markdown-image-preview__zoom-btn {
277
+ border: 1px solid rgb(255 255 255 / 0.4);
278
+ border-radius: 999px;
279
+ background: rgb(0 0 0 / 0.6);
280
+ color: #fff;
281
+ min-width: 2rem;
282
+ height: 2rem;
283
+ cursor: pointer;
284
+ font-size: 0.95rem;
285
+ }
286
+
287
+ .markdown-image-preview__zoom-btn:disabled {
288
+ opacity: 0.45;
289
+ cursor: not-allowed;
290
+ }
291
+
292
+ .markdown-image-preview__zoom-label {
293
+ padding: 0 0.7rem;
294
+ width: auto;
295
+ min-width: 4.25rem;
296
+ }
297
+
298
+ .markdown-image-preview__viewport {
299
+ overflow: auto;
300
+ max-width: min(92vw, 1500px);
301
+ max-height: 84vh;
302
+ padding: 0.15rem;
303
+ cursor: default;
304
+ }
305
+
306
+ .markdown-image-preview__viewport.is-pannable {
307
+ cursor: grab;
308
+ }
309
+
310
+ .markdown-image-preview__viewport.is-dragging {
311
+ cursor: grabbing;
312
+ user-select: none;
313
+ }
314
+
315
+ .markdown-image-preview__image {
316
+ display: block;
317
+ margin: 0 auto;
318
+ max-width: 100%;
319
+ max-height: 82vh;
320
+ border-radius: 0.625rem;
321
+ box-shadow: 0 18px 40px rgb(0 0 0 / 0.45);
322
+ }
323
+
324
+ .markdown-image-preview__close {
325
+ position: absolute;
326
+ top: -0.75rem;
327
+ right: -0.75rem;
328
+ width: 2rem;
329
+ height: 2rem;
330
+ border: 1px solid rgb(255 255 255 / 0.4);
331
+ border-radius: 999px;
332
+ background: rgb(0 0 0 / 0.6);
333
+ color: #fff;
334
+ line-height: 1;
335
+ cursor: pointer;
336
+ }
337
+
338
+ .markdown-image-preview__caption {
339
+ margin-top: 0.65rem;
340
+ margin-bottom: 0;
341
+ color: #fff;
342
+ text-align: center;
343
+ text-shadow: 0 1px 2px rgb(0 0 0 / 0.7);
344
+ }
345
+ </style>