@webmcp-auto-ui/ui 2.5.5 → 2.5.7
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/package.json +1 -1
- package/src/agent/TokenBubble.svelte +16 -3
- package/src/index.ts +5 -0
- package/src/widgets/ExportModal.svelte +117 -0
- package/src/widgets/SafeImage.svelte +1 -0
- package/src/widgets/WidgetRenderer.svelte +26 -1
- package/src/widgets/export-widget.ts +345 -0
- package/src/widgets/rich/Cards.svelte +1 -1
- package/src/widgets/rich/Chart.svelte +34 -3
- package/src/widgets/rich/DataTable.svelte +2 -1
- package/src/widgets/rich/Gallery.svelte +14 -1
- package/src/widgets/rich/GridData.svelte +3 -2
- package/src/widgets/rich/Hemicycle.svelte +4 -2
- package/src/widgets/rich/Timeline.svelte +1 -1
- package/src/widgets/simple/ChartBlock.svelte +1 -1
- package/src/widgets/simple/ListBlock.svelte +2 -1
- package/src/widgets/simple/TextBlock.svelte +117 -1
- package/src/wm/FlexLayout.svelte +1 -0
package/package.json
CHANGED
|
@@ -16,10 +16,19 @@
|
|
|
16
16
|
totalCachedGB: number;
|
|
17
17
|
isWasm: boolean;
|
|
18
18
|
};
|
|
19
|
+
maxContextTokens?: number;
|
|
19
20
|
visible?: boolean;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
let { metrics, visible = true }: Props = $props();
|
|
23
|
+
let { metrics, maxContextTokens, visible = true }: Props = $props();
|
|
24
|
+
|
|
25
|
+
const ctxRatio = $derived(
|
|
26
|
+
maxContextTokens && maxContextTokens > 0 && metrics.lastInputTokens > 0
|
|
27
|
+
? metrics.lastInputTokens / maxContextTokens
|
|
28
|
+
: 0
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const ctxWarn = $derived(ctxRatio > 0.8);
|
|
23
32
|
|
|
24
33
|
// Rate limits (Anthropic tier)
|
|
25
34
|
const LIMITS = { reqPerMin: 1000, inPerMin: 450_000, outPerMin: 90_000 };
|
|
@@ -65,16 +74,20 @@
|
|
|
65
74
|
in:fly={{ y: -8, duration: 200, opacity: 0 }}
|
|
66
75
|
out:fly={{ y: -8, duration: 150, opacity: 0 }}
|
|
67
76
|
>
|
|
77
|
+
{#if maxContextTokens && maxContextTokens > 0 && metrics.lastInputTokens > 0}
|
|
78
|
+
<span class:warn={ctxWarn}>Ctx {fmt(metrics.lastInputTokens)}/{fmt(maxContextTokens)}</span>
|
|
79
|
+
{/if}
|
|
68
80
|
{#if !metrics.isWasm && metrics.totalCacheReadTokens > 0}
|
|
69
81
|
<span class="cached-active">Cached {fmt(metrics.totalCacheReadTokens)}</span>
|
|
70
82
|
{/if}
|
|
83
|
+
<span class="sep">|</span>
|
|
71
84
|
{#if metrics.isWasm}
|
|
72
|
-
<span>Req {metrics.requestsPerMin}/min</span>
|
|
73
85
|
<span>Out {fmt(metrics.outputTokensPerMin)}/min</span>
|
|
86
|
+
<span>Req {metrics.requestsPerMin}/min</span>
|
|
74
87
|
{:else}
|
|
75
|
-
<span class:warn={metrics.requestsPerMin > LIMITS.reqPerMin * 0.8}>Req {metrics.requestsPerMin}/min</span> <span class="limit">({fmt(LIMITS.reqPerMin)} max)</span>
|
|
76
88
|
<span class:warn={metrics.inputTokensPerMin > LIMITS.inPerMin * 0.8}>In {fmt(metrics.inputTokensPerMin)}/min</span> <span class="limit">({fmt(LIMITS.inPerMin)} max)</span>
|
|
77
89
|
<span class:warn={metrics.outputTokensPerMin > LIMITS.outPerMin * 0.8}>Out {fmt(metrics.outputTokensPerMin)}/min</span> <span class="limit">({fmt(LIMITS.outPerMin)} max)</span>
|
|
90
|
+
<span class:warn={metrics.requestsPerMin > LIMITS.reqPerMin * 0.8}>Req {metrics.requestsPerMin}/min</span> <span class="limit">({fmt(LIMITS.reqPerMin)} max)</span>
|
|
78
91
|
{/if}
|
|
79
92
|
</div>
|
|
80
93
|
{/if}
|
package/src/index.ts
CHANGED
|
@@ -46,6 +46,11 @@ export { default as Carousel } from './widgets/rich/Carousel.svelte';
|
|
|
46
46
|
// Safe image (URL validation + error fallback)
|
|
47
47
|
export { default as SafeImage } from './widgets/SafeImage.svelte';
|
|
48
48
|
|
|
49
|
+
// Widget export utility
|
|
50
|
+
export { exportWidget, getExportFormats, exportWidgetAs } from './widgets/export-widget.js';
|
|
51
|
+
export type { ExportFormat } from './widgets/export-widget.js';
|
|
52
|
+
export { default as ExportModal } from './widgets/ExportModal.svelte';
|
|
53
|
+
|
|
49
54
|
// Dispatcher
|
|
50
55
|
export { default as BlockRenderer } from './widgets/BlockRenderer.svelte';
|
|
51
56
|
export { default as WidgetRenderer } from './widgets/WidgetRenderer.svelte';
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getExportFormats, exportWidgetAs, type ExportFormat } from './export-widget.js';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
open: boolean;
|
|
6
|
+
type: string;
|
|
7
|
+
data: Record<string, unknown>;
|
|
8
|
+
containerEl?: HTMLElement;
|
|
9
|
+
onclose?: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let { open = $bindable(false), type, data, containerEl, onclose }: Props = $props();
|
|
13
|
+
|
|
14
|
+
const formats = $derived(open ? getExportFormats(type, containerEl) : []);
|
|
15
|
+
|
|
16
|
+
function doExport(fmt: ExportFormat) {
|
|
17
|
+
exportWidgetAs(fmt.id, type, data, containerEl);
|
|
18
|
+
open = false;
|
|
19
|
+
onclose?.();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function handleBackdrop() {
|
|
23
|
+
open = false;
|
|
24
|
+
onclose?.();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
28
|
+
if (e.key === 'Escape') {
|
|
29
|
+
open = false;
|
|
30
|
+
onclose?.();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
{#if open}
|
|
36
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
37
|
+
<div class="export-modal-backdrop" onclick={handleBackdrop} onkeydown={handleKeydown}>
|
|
38
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
39
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
40
|
+
<div class="export-modal" onclick={(e) => e.stopPropagation()}>
|
|
41
|
+
<div class="export-modal-title">Exporter — {type}</div>
|
|
42
|
+
<div class="export-modal-list">
|
|
43
|
+
{#each formats as fmt}
|
|
44
|
+
<button class="export-modal-btn" onclick={() => doExport(fmt)}>
|
|
45
|
+
<span class="export-modal-icon">{fmt.icon}</span>
|
|
46
|
+
<span class="export-modal-label">{fmt.label}</span>
|
|
47
|
+
</button>
|
|
48
|
+
{/each}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
{/if}
|
|
53
|
+
|
|
54
|
+
<style>
|
|
55
|
+
.export-modal-backdrop {
|
|
56
|
+
position: fixed;
|
|
57
|
+
inset: 0;
|
|
58
|
+
z-index: 9999;
|
|
59
|
+
display: flex;
|
|
60
|
+
align-items: center;
|
|
61
|
+
justify-content: center;
|
|
62
|
+
background: rgba(0, 0, 0, 0.35);
|
|
63
|
+
}
|
|
64
|
+
.export-modal {
|
|
65
|
+
background: var(--color-surface, #1a1a2e);
|
|
66
|
+
border: 1px solid var(--color-border, #333);
|
|
67
|
+
border-radius: 8px;
|
|
68
|
+
padding: 12px;
|
|
69
|
+
max-width: 250px;
|
|
70
|
+
width: 100%;
|
|
71
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
72
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
73
|
+
}
|
|
74
|
+
.export-modal-title {
|
|
75
|
+
font-size: 11px;
|
|
76
|
+
color: var(--color-text2, #888);
|
|
77
|
+
margin-bottom: 8px;
|
|
78
|
+
padding-bottom: 6px;
|
|
79
|
+
border-bottom: 1px solid var(--color-border, #333);
|
|
80
|
+
text-transform: uppercase;
|
|
81
|
+
letter-spacing: 0.05em;
|
|
82
|
+
}
|
|
83
|
+
.export-modal-list {
|
|
84
|
+
display: flex;
|
|
85
|
+
flex-direction: column;
|
|
86
|
+
gap: 4px;
|
|
87
|
+
}
|
|
88
|
+
.export-modal-btn {
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
gap: 8px;
|
|
92
|
+
width: 100%;
|
|
93
|
+
padding: 8px 10px;
|
|
94
|
+
border: 1px solid transparent;
|
|
95
|
+
border-radius: 6px;
|
|
96
|
+
background: transparent;
|
|
97
|
+
color: var(--color-text1, #eee);
|
|
98
|
+
font-family: inherit;
|
|
99
|
+
font-size: 13px;
|
|
100
|
+
cursor: pointer;
|
|
101
|
+
transition: background 0.15s, border-color 0.15s;
|
|
102
|
+
}
|
|
103
|
+
.export-modal-btn:hover {
|
|
104
|
+
background: var(--color-surface2, #2a2a3e);
|
|
105
|
+
border-color: var(--color-border, #444);
|
|
106
|
+
}
|
|
107
|
+
.export-modal-icon {
|
|
108
|
+
font-size: 16px;
|
|
109
|
+
flex-shrink: 0;
|
|
110
|
+
width: 20px;
|
|
111
|
+
text-align: center;
|
|
112
|
+
}
|
|
113
|
+
.export-modal-label {
|
|
114
|
+
flex: 1;
|
|
115
|
+
text-align: left;
|
|
116
|
+
}
|
|
117
|
+
</style>
|
|
@@ -168,6 +168,10 @@
|
|
|
168
168
|
const mc = getMC();
|
|
169
169
|
if (!mc) return;
|
|
170
170
|
|
|
171
|
+
// Unregister first in case widget was re-mounted with same busId
|
|
172
|
+
const toolNames = [`widget_${busId}_get`, `widget_${busId}_update`, `widget_${busId}_remove`];
|
|
173
|
+
toolNames.forEach(n => { try { mc.unregisterTool(n); } catch { /* ok */ } });
|
|
174
|
+
|
|
171
175
|
mc.registerTool({
|
|
172
176
|
name: `widget_${busId}_get`,
|
|
173
177
|
description: `Get current data of ${type} widget (id: ${busId}).`,
|
|
@@ -211,7 +215,7 @@
|
|
|
211
215
|
</script>
|
|
212
216
|
|
|
213
217
|
{#if isVanillaRenderer}
|
|
214
|
-
<div bind:this={vanillaContainer}></div>
|
|
218
|
+
<div bind:this={vanillaContainer} class="vanilla-container w-full h-full overflow-auto p-2"></div>
|
|
215
219
|
{:else if customRenderer}
|
|
216
220
|
<svelte:component this={customRenderer as Component<any>} {data} {id} />
|
|
217
221
|
{:else if nativeEntry}
|
|
@@ -219,3 +223,24 @@
|
|
|
219
223
|
{:else}
|
|
220
224
|
<div class="p-3 font-mono text-xs text-text2">[{type}]</div>
|
|
221
225
|
{/if}
|
|
226
|
+
|
|
227
|
+
<style>
|
|
228
|
+
.vanilla-container :global(svg) {
|
|
229
|
+
width: 100%;
|
|
230
|
+
height: auto;
|
|
231
|
+
max-height: 100%;
|
|
232
|
+
display: block;
|
|
233
|
+
}
|
|
234
|
+
.vanilla-container :global(canvas) {
|
|
235
|
+
width: 100%;
|
|
236
|
+
height: auto;
|
|
237
|
+
max-height: 100%;
|
|
238
|
+
display: block;
|
|
239
|
+
}
|
|
240
|
+
.vanilla-container :global(img) {
|
|
241
|
+
width: 100%;
|
|
242
|
+
height: auto;
|
|
243
|
+
max-height: 100%;
|
|
244
|
+
object-fit: contain;
|
|
245
|
+
}
|
|
246
|
+
</style>
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* export-widget.ts — contextual export utility for widgets
|
|
3
|
+
* Determines the best export format based on widget type and triggers a file download.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
function timestamp(): string {
|
|
9
|
+
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function filename(type: string, ext: string): string {
|
|
13
|
+
return `${type}_${timestamp()}.${ext}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Download a file. Accepts either a data: URL or plain text content.
|
|
18
|
+
*/
|
|
19
|
+
function downloadFile(content: string, name: string, mimeType = 'text/plain'): void {
|
|
20
|
+
const a = document.createElement('a');
|
|
21
|
+
if (content.startsWith('data:')) {
|
|
22
|
+
a.href = content;
|
|
23
|
+
} else {
|
|
24
|
+
const blob = new Blob([content], { type: mimeType });
|
|
25
|
+
a.href = URL.createObjectURL(blob);
|
|
26
|
+
}
|
|
27
|
+
a.download = name;
|
|
28
|
+
a.click();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── CSV ───────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/** Escape a cell value for CSV (RFC 4180). */
|
|
34
|
+
function csvCell(value: unknown): string {
|
|
35
|
+
const str = value === null || value === undefined ? '' : String(value);
|
|
36
|
+
if (str.includes('"') || str.includes(',') || str.includes('\n') || str.includes('\r')) {
|
|
37
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
38
|
+
}
|
|
39
|
+
return str;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function csvRow(cells: unknown[]): string {
|
|
43
|
+
return cells.map(csvCell).join(',');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function exportCsv(type: string, data: Record<string, unknown>): void {
|
|
47
|
+
let lines: string[] = [];
|
|
48
|
+
|
|
49
|
+
if (type === 'data-table') {
|
|
50
|
+
const columns = (data.columns as string[] | undefined) ?? [];
|
|
51
|
+
const rows = (data.rows as (Record<string, unknown> | unknown[])[]) ?? [];
|
|
52
|
+
lines.push(csvRow(columns));
|
|
53
|
+
for (const row of rows) {
|
|
54
|
+
if (Array.isArray(row)) {
|
|
55
|
+
lines.push(csvRow(row));
|
|
56
|
+
} else {
|
|
57
|
+
lines.push(csvRow(columns.map((c) => (row as Record<string, unknown>)[c])));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} else if (type === 'grid-data') {
|
|
61
|
+
const headers = (data.headers as string[] | undefined) ?? [];
|
|
62
|
+
const rows = (data.rows as unknown[][] | undefined) ?? [];
|
|
63
|
+
lines.push(csvRow(headers));
|
|
64
|
+
for (const row of rows) lines.push(csvRow(row));
|
|
65
|
+
} else if (type === 'kv') {
|
|
66
|
+
lines.push(csvRow(['key', 'value']));
|
|
67
|
+
const items = (data.items as { key: unknown; value: unknown }[] | undefined) ?? [];
|
|
68
|
+
for (const item of items) lines.push(csvRow([item.key, item.value]));
|
|
69
|
+
} else if (type === 'list') {
|
|
70
|
+
lines.push(csvRow(['value']));
|
|
71
|
+
const items = (data.items as unknown[] | undefined) ?? [];
|
|
72
|
+
for (const item of items) {
|
|
73
|
+
if (typeof item === 'object' && item !== null && 'label' in item) {
|
|
74
|
+
lines.push(csvRow([(item as { label: unknown }).label]));
|
|
75
|
+
} else {
|
|
76
|
+
lines.push(csvRow([item]));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
downloadFile(lines.join('\r\n'), filename(type, 'csv'), 'text/csv;charset=utf-8');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── PNG via SVG ───────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
function svgToPng(svgEl: SVGElement, name: string): void {
|
|
87
|
+
// Inline computed styles on the SVG to improve fidelity when rendered off-DOM
|
|
88
|
+
const svgData = new XMLSerializer().serializeToString(svgEl);
|
|
89
|
+
const canvas = document.createElement('canvas');
|
|
90
|
+
const ctx = canvas.getContext('2d')!;
|
|
91
|
+
const img = new Image();
|
|
92
|
+
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
|
93
|
+
const url = URL.createObjectURL(blob);
|
|
94
|
+
img.onload = () => {
|
|
95
|
+
canvas.width = img.naturalWidth || svgEl.clientWidth || 800;
|
|
96
|
+
canvas.height = img.naturalHeight || svgEl.clientHeight || 600;
|
|
97
|
+
ctx.drawImage(img, 0, 0);
|
|
98
|
+
const pngUrl = canvas.toDataURL('image/png');
|
|
99
|
+
downloadFile(pngUrl, name);
|
|
100
|
+
URL.revokeObjectURL(url);
|
|
101
|
+
};
|
|
102
|
+
img.onerror = () => {
|
|
103
|
+
// Fallback: try canvas.toDataURL directly if available
|
|
104
|
+
URL.revokeObjectURL(url);
|
|
105
|
+
};
|
|
106
|
+
img.src = url;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function canvasToPng(canvasEl: HTMLCanvasElement, name: string): void {
|
|
110
|
+
const url = canvasEl.toDataURL('image/png');
|
|
111
|
+
downloadFile(url, name);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function exportPng(type: string, containerEl: HTMLElement): void {
|
|
115
|
+
const name = filename(type, 'png');
|
|
116
|
+
|
|
117
|
+
// Prefer canvas (chart.js renders to <canvas>)
|
|
118
|
+
const canvasEl = containerEl.querySelector('canvas');
|
|
119
|
+
if (canvasEl) {
|
|
120
|
+
canvasToPng(canvasEl, name);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Fall back to SVG
|
|
125
|
+
const svgEl = containerEl.querySelector('svg');
|
|
126
|
+
if (svgEl) {
|
|
127
|
+
svgToPng(svgEl as SVGElement, name);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.warn('[exportWidget] No <canvas> or <svg> found in container for type:', type);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Markdown ──────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
function exportMarkdown(type: string, data: Record<string, unknown>): void {
|
|
137
|
+
let content = '';
|
|
138
|
+
|
|
139
|
+
if (type === 'text') {
|
|
140
|
+
content = String((data.content ?? data.text) ?? '');
|
|
141
|
+
} else if (type === 'code') {
|
|
142
|
+
const lang = String(data.language ?? '');
|
|
143
|
+
const code = String(data.code ?? data.content ?? '');
|
|
144
|
+
content = `\`\`\`${lang}\n${code}\n\`\`\``;
|
|
145
|
+
} else if (type === 'log') {
|
|
146
|
+
const entries = (data.entries as unknown[] | undefined) ?? [];
|
|
147
|
+
content = entries.map((e) => (typeof e === 'object' ? JSON.stringify(e) : String(e))).join('\n');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
downloadFile(content, filename(type, 'md'), 'text/markdown;charset=utf-8');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── JSON ──────────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
function exportJson(type: string, data: Record<string, unknown>): void {
|
|
156
|
+
const content = JSON.stringify(data, null, 2);
|
|
157
|
+
downloadFile(content, filename(type, 'json'), 'application/json');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── HTML (js-sandbox) ─────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
function exportHtml(type: string, data: Record<string, unknown>, containerEl?: HTMLElement): void {
|
|
163
|
+
// Try to grab the iframe srcdoc/src first
|
|
164
|
+
const iframeEl = containerEl?.querySelector('iframe');
|
|
165
|
+
let content = '';
|
|
166
|
+
if (iframeEl) {
|
|
167
|
+
try {
|
|
168
|
+
content = iframeEl.contentDocument?.documentElement?.outerHTML ?? '';
|
|
169
|
+
} catch {
|
|
170
|
+
// cross-origin — fall back to data
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!content) {
|
|
174
|
+
content = String(data.html ?? data.source ?? data.content ?? '');
|
|
175
|
+
}
|
|
176
|
+
downloadFile(content, filename(type, 'html'), 'text/html;charset=utf-8');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Gallery / Carousel (JSON of image URLs) ──────────────────────────────────
|
|
180
|
+
|
|
181
|
+
function exportImageUrls(type: string, data: Record<string, unknown>): void {
|
|
182
|
+
const items = (data.items as unknown[] | undefined) ?? (data.images as unknown[] | undefined) ?? [];
|
|
183
|
+
const urls = items.map((item) => {
|
|
184
|
+
if (typeof item === 'string') return item;
|
|
185
|
+
if (typeof item === 'object' && item !== null) {
|
|
186
|
+
return (item as Record<string, unknown>).url ?? (item as Record<string, unknown>).src ?? item;
|
|
187
|
+
}
|
|
188
|
+
return item;
|
|
189
|
+
});
|
|
190
|
+
const content = JSON.stringify({ type, urls }, null, 2);
|
|
191
|
+
downloadFile(content, filename(type, 'json'), 'application/json');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Export Format types ───────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
export interface ExportFormat {
|
|
197
|
+
id: string; // 'csv', 'png', 'json', 'md', 'html'
|
|
198
|
+
label: string; // 'CSV', 'PNG', 'JSON', 'Markdown', 'HTML'
|
|
199
|
+
icon: string; // emoji or unicode
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const CSV_FMT: ExportFormat = { id: 'csv', label: 'CSV', icon: '📊' };
|
|
203
|
+
const PNG_FMT: ExportFormat = { id: 'png', label: 'PNG', icon: '📷' };
|
|
204
|
+
const JSON_FMT: ExportFormat = { id: 'json', label: 'JSON', icon: '📋' };
|
|
205
|
+
const MD_FMT: ExportFormat = { id: 'md', label: 'Markdown', icon: '📝' };
|
|
206
|
+
const HTML_FMT: ExportFormat = { id: 'html', label: 'HTML', icon: '🌐' };
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Return the list of export formats available for a given widget type.
|
|
210
|
+
*/
|
|
211
|
+
export function getExportFormats(type: string, containerEl?: HTMLElement): ExportFormat[] {
|
|
212
|
+
switch (type) {
|
|
213
|
+
case 'data-table':
|
|
214
|
+
case 'grid-data':
|
|
215
|
+
case 'kv':
|
|
216
|
+
case 'list':
|
|
217
|
+
return [CSV_FMT, JSON_FMT];
|
|
218
|
+
|
|
219
|
+
case 'chart':
|
|
220
|
+
case 'chart-rich':
|
|
221
|
+
case 'sankey':
|
|
222
|
+
case 'd3':
|
|
223
|
+
case 'hemicycle':
|
|
224
|
+
case 'map':
|
|
225
|
+
return [PNG_FMT, JSON_FMT];
|
|
226
|
+
|
|
227
|
+
case 'text':
|
|
228
|
+
case 'code':
|
|
229
|
+
case 'log':
|
|
230
|
+
return [MD_FMT, JSON_FMT];
|
|
231
|
+
|
|
232
|
+
case 'js-sandbox':
|
|
233
|
+
return [HTML_FMT, JSON_FMT];
|
|
234
|
+
|
|
235
|
+
case 'gallery':
|
|
236
|
+
case 'carousel':
|
|
237
|
+
return [JSON_FMT];
|
|
238
|
+
|
|
239
|
+
default:
|
|
240
|
+
return [JSON_FMT];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Export a widget in a specific format chosen by the user.
|
|
246
|
+
*/
|
|
247
|
+
export function exportWidgetAs(
|
|
248
|
+
format: string,
|
|
249
|
+
type: string,
|
|
250
|
+
data: Record<string, unknown>,
|
|
251
|
+
containerEl?: HTMLElement
|
|
252
|
+
): void {
|
|
253
|
+
switch (format) {
|
|
254
|
+
case 'csv':
|
|
255
|
+
exportCsv(type, data);
|
|
256
|
+
break;
|
|
257
|
+
case 'png':
|
|
258
|
+
if (containerEl) exportPng(type, containerEl);
|
|
259
|
+
else console.warn('[exportWidgetAs] containerEl required for PNG export');
|
|
260
|
+
break;
|
|
261
|
+
case 'md':
|
|
262
|
+
exportMarkdown(type, data);
|
|
263
|
+
break;
|
|
264
|
+
case 'html':
|
|
265
|
+
exportHtml(type, data, containerEl);
|
|
266
|
+
break;
|
|
267
|
+
case 'json':
|
|
268
|
+
default:
|
|
269
|
+
exportJson(type, data);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Export a widget's data in the most appropriate format for its type.
|
|
278
|
+
* @deprecated Use getExportFormats() + exportWidgetAs() for format selection modal.
|
|
279
|
+
*
|
|
280
|
+
* @param type Widget type identifier (e.g. "data-table", "chart", "text")
|
|
281
|
+
* @param data The widget's data object
|
|
282
|
+
* @param containerEl Optional DOM container — required for PNG exports
|
|
283
|
+
*/
|
|
284
|
+
export function exportWidget(
|
|
285
|
+
type: string,
|
|
286
|
+
data: Record<string, unknown>,
|
|
287
|
+
containerEl?: HTMLElement
|
|
288
|
+
): void {
|
|
289
|
+
switch (type) {
|
|
290
|
+
// ── CSV ──
|
|
291
|
+
case 'data-table':
|
|
292
|
+
case 'grid-data':
|
|
293
|
+
case 'kv':
|
|
294
|
+
case 'list':
|
|
295
|
+
exportCsv(type, data);
|
|
296
|
+
break;
|
|
297
|
+
|
|
298
|
+
// ── PNG ──
|
|
299
|
+
case 'chart':
|
|
300
|
+
case 'chart-rich':
|
|
301
|
+
case 'sankey':
|
|
302
|
+
case 'd3':
|
|
303
|
+
case 'hemicycle':
|
|
304
|
+
case 'map':
|
|
305
|
+
if (containerEl) {
|
|
306
|
+
exportPng(type, containerEl);
|
|
307
|
+
} else {
|
|
308
|
+
console.warn('[exportWidget] containerEl required for PNG export of type:', type);
|
|
309
|
+
}
|
|
310
|
+
break;
|
|
311
|
+
|
|
312
|
+
// ── Markdown ──
|
|
313
|
+
case 'text':
|
|
314
|
+
case 'code':
|
|
315
|
+
case 'log':
|
|
316
|
+
exportMarkdown(type, data);
|
|
317
|
+
break;
|
|
318
|
+
|
|
319
|
+
// ── HTML ──
|
|
320
|
+
case 'js-sandbox':
|
|
321
|
+
exportHtml(type, data, containerEl);
|
|
322
|
+
break;
|
|
323
|
+
|
|
324
|
+
// ── Image URL lists ──
|
|
325
|
+
case 'gallery':
|
|
326
|
+
case 'carousel':
|
|
327
|
+
exportImageUrls(type, data);
|
|
328
|
+
break;
|
|
329
|
+
|
|
330
|
+
// ── JSON (everything else) ──
|
|
331
|
+
case 'json-viewer':
|
|
332
|
+
case 'profile':
|
|
333
|
+
case 'stat-card':
|
|
334
|
+
case 'stat':
|
|
335
|
+
case 'tags':
|
|
336
|
+
case 'alert':
|
|
337
|
+
case 'actions':
|
|
338
|
+
case 'cards':
|
|
339
|
+
case 'timeline':
|
|
340
|
+
case 'trombinoscope':
|
|
341
|
+
default:
|
|
342
|
+
exportJson(type, data);
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
21
21
|
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
|
22
22
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
23
|
-
<div class="bg-surface2 border border-border rounded-lg overflow-hidden hover:border-border2 transition-all {oncardclick?'cursor-pointer':''}" role={oncardclick?"button":undefined} tabindex={oncardclick?0:undefined}
|
|
23
|
+
<div class="bg-surface2 border border-border rounded-lg overflow-hidden hover:border-border2 transition-all {oncardclick?'cursor-pointer':''}" role={oncardclick?"button":undefined} tabindex={oncardclick?0:undefined} title={oncardclick?"Double-cliquez pour interagir":undefined} ondblclick={()=>oncardclick?.(card)}>
|
|
24
24
|
{#if card.image}<SafeImage src={card.image} alt={card.title} class="w-full h-32 object-cover" hideOnError />{/if}
|
|
25
25
|
<div class="p-3">
|
|
26
26
|
<div class="font-semibold text-sm text-text1 leading-tight">{card.title}</div>
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
export interface ChartSpec { title?: string; type?: 'bar'|'line'|'area'|'pie'|'donut'; labels?: string[]; data?: ChartDataset[]; legend?: boolean; xAxis?: {label?:string}; yAxis?: {label?:string}; }
|
|
4
4
|
interface Props { spec: Partial<ChartSpec>; }
|
|
5
5
|
let { spec }: Props = $props();
|
|
6
|
-
const PAL=[
|
|
6
|
+
const PAL=[
|
|
7
|
+
'#6366f1','#f59e0b','#10b981','#ef4444','#8b5cf6',
|
|
8
|
+
'#06b6d4','#f97316','#ec4899','#14b8a6','#a855f7',
|
|
9
|
+
'#eab308','#3b82f6','#22c55e','#e11d48','#0ea5e9',
|
|
10
|
+
];
|
|
7
11
|
const fmt=new Intl.NumberFormat('fr-FR');
|
|
8
12
|
const datasets=$derived<ChartDataset[]>(Array.isArray(spec.data)?spec.data:[]);
|
|
9
13
|
const labels=$derived<string[]>(Array.isArray(spec.labels)?spec.labels:[]);
|
|
@@ -14,6 +18,10 @@
|
|
|
14
18
|
const xLabels=$derived(labels.length>0?labels:(datasets[0]?.values??[]).map((_:number,i:number)=>String(i+1)));
|
|
15
19
|
const showLegend=$derived(spec.legend!==false&&datasets.length>1);
|
|
16
20
|
function col(ds:ChartDataset,i:number){return ds.color??PAL[i%PAL.length];}
|
|
21
|
+
/** For single-series bar charts, color each bar distinctly by x-index */
|
|
22
|
+
const isCategoricalBar=$derived(type==='bar'&&datasets.length===1);
|
|
23
|
+
const isCategoricalLine=$derived((type==='line'||type==='area')&&datasets.length===1);
|
|
24
|
+
function barCol(ds:ChartDataset,di:number,xi:number){return isCategoricalBar?PAL[xi%PAL.length]:col(ds,di);}
|
|
17
25
|
// Pie
|
|
18
26
|
const pieTotal=$derived.by<number>(()=>{const ds=datasets[0];if(!ds)return 1;return ds.values.reduce((a,b)=>a+b,0)||1;});
|
|
19
27
|
interface Slice{label:string;value:number;color:string;startAngle:number;endAngle:number;pct:number}
|
|
@@ -71,7 +79,7 @@
|
|
|
71
79
|
{@const v=ds.values[xi]??0}
|
|
72
80
|
{@const pct=Math.round(v/maxVal*100)}
|
|
73
81
|
<div class="flex-1 rounded-t transition-all hover:opacity-80 cursor-default"
|
|
74
|
-
style="height:{pct}%;background:{
|
|
82
|
+
style="height:{pct}%;background:{barCol(ds,di,xi)};"
|
|
75
83
|
title="{ds.label??''} {lbl}: {fmt.format(v)}">
|
|
76
84
|
</div>
|
|
77
85
|
{/each}
|
|
@@ -90,6 +98,11 @@
|
|
|
90
98
|
<polygon points="{pad},{H-pad} {pts.join(' ')} {pad+((ds.values.length-1)*((W-pad*2)/(ds.values.length-1||1)))},{H-pad}" fill={col(ds,di)} opacity="0.15"/>
|
|
91
99
|
{/if}
|
|
92
100
|
<polyline points={pts.join(' ')} fill="none" stroke={col(ds,di)} stroke-width="2" stroke-linejoin="round"/>
|
|
101
|
+
{#each ds.values as v, i}
|
|
102
|
+
{@const cx = pad + i * ((W - pad * 2) / (ds.values.length - 1 || 1))}
|
|
103
|
+
{@const cy = H - pad - (v / maxVal) * (H - pad * 2)}
|
|
104
|
+
<circle cx={cx} cy={cy} r="4" fill={isCategoricalLine ? PAL[i % PAL.length] : col(ds, di)} stroke="var(--color-surface, white)" stroke-width="1.5" />
|
|
105
|
+
{/each}
|
|
93
106
|
{/each}
|
|
94
107
|
</svg>
|
|
95
108
|
<div class="flex gap-1">
|
|
@@ -97,7 +110,25 @@
|
|
|
97
110
|
</div>
|
|
98
111
|
{/if}
|
|
99
112
|
</div>
|
|
100
|
-
{#if
|
|
113
|
+
{#if isCategoricalLine && xLabels.length>1}
|
|
114
|
+
<div class="flex gap-3 flex-wrap mt-2">
|
|
115
|
+
{#each xLabels as lbl, xi}
|
|
116
|
+
<div class="flex items-center gap-1 text-xs">
|
|
117
|
+
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background:{PAL[xi%PAL.length]};"></div>
|
|
118
|
+
<span class="text-text2">{lbl}</span>
|
|
119
|
+
</div>
|
|
120
|
+
{/each}
|
|
121
|
+
</div>
|
|
122
|
+
{:else if isCategoricalBar && xLabels.length>1}
|
|
123
|
+
<div class="flex gap-3 flex-wrap mt-2">
|
|
124
|
+
{#each xLabels as lbl,xi}
|
|
125
|
+
<div class="flex items-center gap-1 text-xs">
|
|
126
|
+
<div class="w-2.5 h-2.5 rounded-sm flex-shrink-0" style="background:{PAL[xi%PAL.length]};"></div>
|
|
127
|
+
<span class="text-text2">{lbl}</span>
|
|
128
|
+
</div>
|
|
129
|
+
{/each}
|
|
130
|
+
</div>
|
|
131
|
+
{:else if showLegend}
|
|
101
132
|
<div class="flex gap-3 flex-wrap mt-2">
|
|
102
133
|
{#each datasets as ds,i}
|
|
103
134
|
<div class="flex items-center gap-1 text-xs">
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
<tbody>
|
|
38
38
|
{#each displayed as row, i}
|
|
39
39
|
<tr class="hover:bg-surface2 transition-colors {spec.striped!==false&&i%2===1?'bg-white/[0.02]':''} {onrowclick?'cursor-pointer':''}"
|
|
40
|
-
|
|
40
|
+
title={onrowclick?"Double-cliquez pour interagir":undefined}
|
|
41
|
+
ondblclick={()=>onrowclick?.(row)}>
|
|
41
42
|
{#each columns as col}
|
|
42
43
|
{@const val=row[col.key]}
|
|
43
44
|
<td class="border-b border-border text-text1 overflow-hidden text-ellipsis whitespace-nowrap max-w-[280px] {compact?'px-2 py-1 text-xs':'px-3 py-2 text-sm'} {col.align==='center'?'text-center':col.align==='right'?'text-right':''}">
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
|
|
17
17
|
function open(i: number) {
|
|
18
18
|
lightboxIdx = i;
|
|
19
|
+
}
|
|
20
|
+
function interact(i: number) {
|
|
19
21
|
onimageclick?.(images[i], i);
|
|
20
22
|
}
|
|
21
23
|
function close() { lightboxIdx = null; }
|
|
@@ -35,11 +37,22 @@
|
|
|
35
37
|
{#if spec.title}<h3 class="text-sm font-semibold text-text1 mb-3">{spec.title}</h3>{/if}
|
|
36
38
|
{#if images.length === 0}
|
|
37
39
|
<p class="text-text2 text-sm">{spec.emptyMessage ?? 'Aucune image'}</p>
|
|
40
|
+
{:else if images.length === 1}
|
|
41
|
+
{@const img = images[0]}
|
|
42
|
+
<button class="relative overflow-hidden rounded-lg border border-border hover:border-border2 transition-all cursor-pointer bg-transparent p-0 group w-full"
|
|
43
|
+
onclick={() => open(0)}>
|
|
44
|
+
<SafeImage src={img.src} alt={img.alt ?? ''} class="w-full max-h-[400px] object-contain rounded-lg" loading="eager" />
|
|
45
|
+
{#if img.caption || img.alt}
|
|
46
|
+
<div class="mt-2 text-center text-xs text-text2">{img.caption ?? img.alt}</div>
|
|
47
|
+
{/if}
|
|
48
|
+
</button>
|
|
38
49
|
{:else}
|
|
39
50
|
<div class="grid gap-2 responsive-gallery" style="--gallery-cols: repeat({spec.columns ?? 3}, minmax(0, 1fr));">
|
|
40
51
|
{#each images as img, i}
|
|
41
52
|
<button class="relative overflow-hidden rounded-lg border border-border hover:border-border2 transition-all cursor-pointer bg-transparent p-0 group"
|
|
42
|
-
|
|
53
|
+
title={onimageclick?"Double-cliquez pour interagir":undefined}
|
|
54
|
+
onclick={() => open(i)}
|
|
55
|
+
ondblclick={() => interact(i)}>
|
|
43
56
|
<SafeImage src={img.src} alt={img.alt ?? ''} class="w-full h-32 sm:h-40 object-cover transition-transform group-hover:scale-105" loading="lazy" />
|
|
44
57
|
{#if img.caption}
|
|
45
58
|
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1.5">
|
|
@@ -30,9 +30,10 @@
|
|
|
30
30
|
{#each rows as row, ri}
|
|
31
31
|
<tr class="hover:bg-surface2">
|
|
32
32
|
{#each (Array.isArray(row)?row:[]) as cell, ci}
|
|
33
|
-
<td class="px-3 text-text2 border-b border-r border-border cursor-pointer hover:bg-surface2"
|
|
33
|
+
<td class="px-3 text-text2 border-b border-r border-border {oncellclick?'cursor-pointer':''} hover:bg-surface2"
|
|
34
34
|
style="height:{cellH}px;{bg(ri,ci)?`background:${bg(ri,ci)};`:''}"
|
|
35
|
-
|
|
35
|
+
title={oncellclick?"Double-cliquez pour interagir":undefined}
|
|
36
|
+
ondblclick={()=>oncellclick?.(ri,ci,cell)}>{dv(cell)}</td>
|
|
36
37
|
{/each}
|
|
37
38
|
</tr>
|
|
38
39
|
{/each}
|
|
@@ -51,7 +51,9 @@
|
|
|
51
51
|
class={s.gid?'cursor-pointer':''}
|
|
52
52
|
onmouseenter={()=>{const g=groups.find(g=>g.id===s.gid);if(g)tooltip={label:g.label,seats:g.seats};}}
|
|
53
53
|
onmouseleave={()=>tooltip=null}
|
|
54
|
-
|
|
54
|
+
ondblclick={()=>{const g=groups.find(g=>g.id===s.gid);if(g)ongroupclick?.(g);}}>
|
|
55
|
+
{#if s.gid}<title>Double-cliquez pour interagir</title>{/if}
|
|
56
|
+
</circle>
|
|
55
57
|
{/each}
|
|
56
58
|
<text x={cx} y={cy+18} text-anchor="middle" font-size="11" fill="var(--color-text2)" font-family="system-ui">{total} sièges</text>
|
|
57
59
|
</svg>
|
|
@@ -65,7 +67,7 @@
|
|
|
65
67
|
{#each legend as g}
|
|
66
68
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
67
69
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
68
|
-
<div class="flex items-center gap-1.5 text-xs cursor-pointer hover:opacity-80" role="button" tabindex="0"
|
|
70
|
+
<div class="flex items-center gap-1.5 text-xs cursor-pointer hover:opacity-80" role="button" tabindex="0" title="Double-cliquez pour interagir" ondblclick={()=>ongroupclick?.(g)} onkeydown={(e)=>{if(e.key==="Enter")ongroupclick?.(g)}}>
|
|
69
71
|
<div class="w-3 h-3 rounded-full flex-shrink-0" style="background:{g.color};"></div>
|
|
70
72
|
<span class="text-text2">{g.label}</span>
|
|
71
73
|
<span class="text-text2">{g.seats}</span>
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
19
19
|
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
|
20
20
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
21
|
-
<div class="flex gap-4 relative {!isLast?'pb-5':''} {oneventclick?'cursor-pointer':''}" role={oneventclick?"button":undefined} tabindex={oneventclick?0:undefined}
|
|
21
|
+
<div class="flex gap-4 relative {!isLast?'pb-5':''} {oneventclick?'cursor-pointer':''}" role={oneventclick?"button":undefined} tabindex={oneventclick?0:undefined} title={oneventclick?"Double-cliquez pour interagir":undefined} ondblclick={()=>oneventclick?.(event)} onkeydown={(e)=>{if(e.key==='Enter')oneventclick?.(event)}}>
|
|
22
22
|
<div class="flex flex-col items-center flex-shrink-0">
|
|
23
23
|
<div class="w-3 h-3 rounded-full flex-shrink-0 mt-0.5" style="background:{dotColor};{event.status==='active'?`box-shadow:0 0 0 3px ${dotColor}33;`:''}"></div>
|
|
24
24
|
{#if !isLast}<div class="w-0.5 flex-1 bg-border mt-1"></div>{/if}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
{#if data.title}<div class="text-[10px] font-mono text-text2 mb-4 uppercase tracking-widest">{data.title}</div>{/if}
|
|
10
10
|
<div class="flex items-end gap-1.5 h-32">
|
|
11
11
|
{#each bars as [, val]}
|
|
12
|
-
<div class="flex-1 rounded-t bg-accent
|
|
12
|
+
<div class="flex-1 rounded-t bg-accent opacity-80 hover:opacity-100 transition-all"
|
|
13
13
|
style="height: max(2px, {Math.round(val / max * 100)}%)"></div>
|
|
14
14
|
{/each}
|
|
15
15
|
</div>
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
11
11
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
12
12
|
<li class="text-sm text-text1 bg-surface2 rounded px-3 py-2 border-l-2 border-accent {onitemclick ? 'cursor-pointer hover:bg-surface2/80' : ''}"
|
|
13
|
-
|
|
13
|
+
title={onitemclick?"Double-cliquez pour interagir":undefined}
|
|
14
|
+
ondblclick={() => onitemclick?.(item, i)}>{item}</li>
|
|
14
15
|
{/each}
|
|
15
16
|
</ul>
|
|
16
17
|
</div>
|
|
@@ -2,5 +2,121 @@
|
|
|
2
2
|
export interface TextBlockData { content?: string; }
|
|
3
3
|
interface Props { data: Partial<TextBlockData>; }
|
|
4
4
|
let { data }: Props = $props();
|
|
5
|
+
|
|
6
|
+
/** Minimal markdown → HTML renderer (no deps, naturally XSS-safe: only produces known tags) */
|
|
7
|
+
function renderMarkdown(src: string): string {
|
|
8
|
+
if (!src) return '';
|
|
9
|
+
|
|
10
|
+
// Escape HTML entities first (XSS protection)
|
|
11
|
+
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
12
|
+
|
|
13
|
+
const lines = src.split('\n');
|
|
14
|
+
const out: string[] = [];
|
|
15
|
+
let inCode = false;
|
|
16
|
+
let codeLines: string[] = [];
|
|
17
|
+
let inUl = false;
|
|
18
|
+
let inOl = false;
|
|
19
|
+
|
|
20
|
+
const closeList = () => {
|
|
21
|
+
if (inUl) { out.push('</ul>'); inUl = false; }
|
|
22
|
+
if (inOl) { out.push('</ol>'); inOl = false; }
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Inline formatting: bold, italic, code, links */
|
|
26
|
+
const inline = (s: string): string => {
|
|
27
|
+
return esc(s)
|
|
28
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
29
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
30
|
+
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
|
31
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
// Fenced code block toggle
|
|
36
|
+
if (line.trimStart().startsWith('```')) {
|
|
37
|
+
if (!inCode) {
|
|
38
|
+
closeList();
|
|
39
|
+
inCode = true;
|
|
40
|
+
codeLines = [];
|
|
41
|
+
} else {
|
|
42
|
+
out.push(`<pre><code>${esc(codeLines.join('\n'))}</code></pre>`);
|
|
43
|
+
inCode = false;
|
|
44
|
+
}
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (inCode) { codeLines.push(line); continue; }
|
|
48
|
+
|
|
49
|
+
const trimmed = line.trim();
|
|
50
|
+
|
|
51
|
+
// Empty line → close lists, push break
|
|
52
|
+
if (!trimmed) { closeList(); out.push(''); continue; }
|
|
53
|
+
|
|
54
|
+
// Headers
|
|
55
|
+
const hMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
|
56
|
+
if (hMatch) {
|
|
57
|
+
closeList();
|
|
58
|
+
const level = hMatch[1].length;
|
|
59
|
+
out.push(`<h${level}>${inline(hMatch[2])}</h${level}>`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Unordered list
|
|
64
|
+
if (/^[-*+]\s+/.test(trimmed)) {
|
|
65
|
+
if (inOl) { out.push('</ol>'); inOl = false; }
|
|
66
|
+
if (!inUl) { out.push('<ul>'); inUl = true; }
|
|
67
|
+
out.push(`<li>${inline(trimmed.replace(/^[-*+]\s+/, ''))}</li>`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Ordered list
|
|
72
|
+
const olMatch = trimmed.match(/^(\d+)\.\s+(.+)$/);
|
|
73
|
+
if (olMatch) {
|
|
74
|
+
if (inUl) { out.push('</ul>'); inUl = false; }
|
|
75
|
+
if (!inOl) { out.push('<ol>'); inOl = true; }
|
|
76
|
+
out.push(`<li>${inline(olMatch[2])}</li>`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Horizontal rule
|
|
81
|
+
if (/^[-*_]{3,}$/.test(trimmed)) {
|
|
82
|
+
closeList();
|
|
83
|
+
out.push('<hr>');
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Regular paragraph line
|
|
88
|
+
closeList();
|
|
89
|
+
out.push(`<p>${inline(trimmed)}</p>`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Close any open blocks
|
|
93
|
+
if (inCode) out.push(`<pre><code>${esc(codeLines.join('\n'))}</code></pre>`);
|
|
94
|
+
closeList();
|
|
95
|
+
|
|
96
|
+
return out.join('\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let rendered = $derived(renderMarkdown(data.content ?? ''));
|
|
5
100
|
</script>
|
|
6
|
-
<div class="p-4 md:p-5 text-sm
|
|
101
|
+
<div class="tb-md p-4 md:p-5 text-sm leading-relaxed">{@html rendered}</div>
|
|
102
|
+
|
|
103
|
+
<style>
|
|
104
|
+
.tb-md { color: var(--color-text2); }
|
|
105
|
+
.tb-md :global(h1) { font-size: 1.5em; font-weight: 700; color: var(--color-text1); margin: 0.8em 0 0.4em; }
|
|
106
|
+
.tb-md :global(h2) { font-size: 1.25em; font-weight: 600; color: var(--color-text1); margin: 0.7em 0 0.35em; }
|
|
107
|
+
.tb-md :global(h3) { font-size: 1.1em; font-weight: 600; color: var(--color-text1); margin: 0.6em 0 0.3em; }
|
|
108
|
+
.tb-md :global(h4), .tb-md :global(h5), .tb-md :global(h6) { font-size: 1em; font-weight: 600; color: var(--color-text1); margin: 0.5em 0 0.25em; }
|
|
109
|
+
.tb-md :global(p) { margin: 0.4em 0; }
|
|
110
|
+
.tb-md :global(strong) { font-weight: 600; color: var(--color-text1); }
|
|
111
|
+
.tb-md :global(em) { font-style: italic; }
|
|
112
|
+
.tb-md :global(a) { color: var(--color-accent); text-decoration: underline; text-underline-offset: 2px; }
|
|
113
|
+
.tb-md :global(a:hover) { opacity: 0.8; }
|
|
114
|
+
.tb-md :global(ul), .tb-md :global(ol) { margin: 0.4em 0; padding-left: 1.5em; }
|
|
115
|
+
.tb-md :global(ul) { list-style: disc; }
|
|
116
|
+
.tb-md :global(ol) { list-style: decimal; }
|
|
117
|
+
.tb-md :global(li) { margin: 0.15em 0; }
|
|
118
|
+
.tb-md :global(code) { font-family: 'IBM Plex Mono', ui-monospace, monospace; font-size: 0.9em; background: var(--color-surface2); padding: 0.15em 0.35em; border-radius: 4px; }
|
|
119
|
+
.tb-md :global(pre) { background: var(--color-surface2); border-radius: 6px; padding: 0.75em 1em; margin: 0.5em 0; overflow-x: auto; }
|
|
120
|
+
.tb-md :global(pre code) { background: none; padding: 0; font-size: 0.85em; }
|
|
121
|
+
.tb-md :global(hr) { border: none; border-top: 1px solid var(--color-surface2); margin: 0.8em 0; }
|
|
122
|
+
</style>
|