@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.
- package/docs/guide/asset-handling.md +15 -0
- package/docs/guide/markdown/links-and-toc.md +16 -0
- package/package.json +2 -1
- package/public/book-color.svg +5 -0
- package/public/book.svg +5 -0
- package/public/favicon-dark.svg +36 -0
- package/public/favicon-light.svg +36 -0
- package/public/favicon.svg +44 -0
- package/public/greg-logo-dark.svg +92 -0
- package/public/greg-logo-light.svg +92 -0
- package/public/greg-logo.svg +108 -0
- package/public/pure.html +15 -0
- package/public/quaggan.png +0 -0
- package/public/svelte.svg +1 -0
- package/src/lib/MarkdownDocs/MarkdownDocs.svelte +4 -0
- package/src/lib/MarkdownDocs/MarkdownRenderer.svelte +21 -1
- package/src/lib/MarkdownDocs/__tests__/helpers.js +3 -0
- package/src/lib/MarkdownDocs/__tests__/markdown.test.js +51 -0
- package/src/lib/MarkdownDocs/markdownRendererRuntime.ts +12 -0
- package/src/lib/MarkdownDocs/remarkFlexRow.js +86 -0
- package/src/lib/MarkdownDocs/vitePluginCopyDocs.js +44 -21
- package/src/lib/MarkdownDocs/vitePluginFrontmatter.js +11 -1
- package/src/lib/components/MarkdownImagePreview.svelte +345 -0
- package/src/lib/scss/__markdown.scss +21 -0
- package/src/lib/scss/__scrollbar.scss +11 -0
- package/svelte.config.js +2 -0
- package/types/index.d.ts +2 -0
|
@@ -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\n\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,\n\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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
90
|
-
const
|
|
91
|
-
? (url === rootPrefix || url.startsWith(rootPrefix + '/'))
|
|
92
|
-
: url.startsWith('/')
|
|
93
|
-
if (
|
|
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',
|
|
99
|
-
res.end(fs.readFileSync(filePath
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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>
|