@djangocfg/widget-media 0.1.1

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.
@@ -0,0 +1,328 @@
1
+ // Mermaid source → SVG string. No React, no DOM.
2
+ //
3
+ // May import: beautiful-mermaid.
4
+
5
+ import { renderMermaidSVG } from 'beautiful-mermaid';
6
+
7
+ /**
8
+ * The palette, as CSS variables rather than resolved colours.
9
+ *
10
+ * `beautiful-mermaid` writes these straight into the SVG's `fill`/`stroke`, so
11
+ * passing variables makes one rendered diagram correct in BOTH themes and
12
+ * follow a theme switch with no re-render. Resolving them here would need
13
+ * `getComputedStyle`, which is a DOM call in a module whose whole value is that
14
+ * it does not need one.
15
+ */
16
+ const PALETTE = {
17
+ bg: 'var(--card)',
18
+ fg: 'var(--card-foreground)',
19
+ muted: 'var(--muted-foreground)',
20
+ border: 'var(--border)',
21
+ accent: 'var(--primary)',
22
+ transparent: true,
23
+ } as const;
24
+
25
+ export type DiagramResult =
26
+ | { readonly ok: true; readonly svg: string }
27
+ | { readonly ok: false; readonly error: string };
28
+
29
+ /**
30
+ * Render a diagram, or say why not.
31
+ *
32
+ * A Result rather than a throw because BOTH outcomes are renderable: an
33
+ * unsupported diagram type falls back to its own source, which is still the
34
+ * answer the agent gave. `pie` is the measured example — the parser covers
35
+ * flowchart, sequence, state, class and ER, and rejects the rest by header.
36
+ */
37
+ export function renderDiagram(source: string): DiagramResult {
38
+ try {
39
+ return { ok: true, svg: renderMermaidSVG(retintToTheme(normalize(source)), PALETTE) };
40
+ } catch (error) {
41
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
42
+ }
43
+ }
44
+
45
+ /** A `style`/`classDef`/`linkStyle` line, which is where authored colour lives. */
46
+ const COLOUR_DIRECTIVE = /^\s*(style|classDef|linkStyle)\s+/;
47
+
48
+ /**
49
+ * A literal colour: hex, `rgb(...)`, `hsl(...)`, or a CSS named colour.
50
+ *
51
+ * Named colours are matched as a bare word after `fill:`/`stroke:` rather than
52
+ * against a list of 148 names — the position is what makes it a colour, and a
53
+ * list would go stale the moment a model reaches for one it lacks.
54
+ */
55
+ const LITERAL_COLOUR = /(fill|stroke|color)\s*:\s*(#[0-9A-Fa-f]{3,8}|rgba?\([^)]*\)|hsla?\([^)]*\)|[A-Za-z]+)/g;
56
+
57
+ /**
58
+ * Which token PAIR a literal fill becomes.
59
+ *
60
+ * A fill alone is half a decision. The design system ships every surface token
61
+ * with its own foreground precisely because contrast is a property of the
62
+ * PAIR — `--primary` is a saturated blue in both themes while
63
+ * `--card-foreground` flips, so a fill taken without its partner puts dark text
64
+ * on that blue in the light theme and reads as unstyled.
65
+ *
66
+ * Three pairs, cycled by order of appearance, so a diagram that painted its
67
+ * nodes differently still READS differently — the distinction the author drew
68
+ * survives even though their exact hue does not. All six are semantic tokens,
69
+ * so both themes stay correct and a theme switch needs no re-render.
70
+ *
71
+ * `--primary` leads because a model's first colour is usually its emphasis.
72
+ *
73
+ * `--muted` is deliberately NOT among them, and the reason is that its
74
+ * `-foreground` does not mean what the others' do. Measured as lightness
75
+ * distance INSIDE each pair against the shipped theme:
76
+ *
77
+ * | pair | light | dark |
78
+ * |---|---|---|
79
+ * | `primary` | 55.0 | 51.0 |
80
+ * | `secondary` | 89.5 | 78.6 |
81
+ * | `accent` | 84.5 | 73.0 |
82
+ * | `muted` | **47.0** | **47.2** |
83
+ *
84
+ * `--muted-foreground` is the token for de-emphasised text on the ORDINARY
85
+ * ground, not for text sitting on `--muted` — so a node tinted with it renders
86
+ * grey-on-grey and its label disappears in both themes. Every other pair states
87
+ * a real figure/ground relationship.
88
+ *
89
+ * `--accent` is pale against the page in the light theme, which is what the
90
+ * stroke is for: the outline gives the shape an edge the fill alone does not.
91
+ */
92
+ const FILL_PAIRS = [
93
+ { fill: 'var(--primary)', text: 'var(--primary-foreground)' },
94
+ { fill: 'var(--secondary)', text: 'var(--secondary-foreground)' },
95
+ { fill: 'var(--accent)', text: 'var(--accent-foreground)' },
96
+ ] as const;
97
+
98
+ type FillPair = (typeof FILL_PAIRS)[number];
99
+
100
+ /**
101
+ * The token NAMES behind `FILL_PAIRS`, for the gate that measures their
102
+ * contrast against the shipped theme.
103
+ *
104
+ * Derived rather than restated: a second hand-kept copy of this list drifted
105
+ * within a day of being written, leaving the gate asserting over a pair the
106
+ * renderer had stopped using.
107
+ */
108
+ export const TINT_TOKENS = FILL_PAIRS.map(({ fill }) =>
109
+ fill.replace(/^var\(--|\)$/g, ''),
110
+ );
111
+
112
+ /** An outline is structure, not emphasis, so every stroke follows one token. */
113
+ const STROKE_TOKEN = 'var(--border)';
114
+
115
+ /**
116
+ * Re-tint the model's own colours onto theme tokens.
117
+ *
118
+ * A model asked for a flowchart routinely paints it — `style A fill:#90EE90`,
119
+ * a yellow decision diamond, a salmon branch. Those literals reach the SVG
120
+ * untouched while the frame and the LABEL come from the palette above, so the
121
+ * two halves answer to different themes: pale fills carry light-theme
122
+ * assumptions, and the label on them is `var(--card-foreground)`, near-white in
123
+ * the dark theme. White text on pale green.
124
+ *
125
+ * The palette cannot win that by being better chosen — one side is a literal on
126
+ * the shape, the other a variable on the text, so whichever theme is active one
127
+ * of the two is wrong.
128
+ *
129
+ * DELETING the directives fixes the contrast and flattens the diagram to one
130
+ * colour, which throws away a distinction the author drew. Measured instead:
131
+ * `style A fill:var(--primary)` passes through the parser intact. So each
132
+ * distinct authored colour is mapped to a distinct semantic token, in order of
133
+ * appearance and consistently across the diagram — the emphasis survives, the
134
+ * hue does not, and both themes stay correct with no re-render.
135
+ *
136
+ * Only inside a `style`/`classDef`/`linkStyle` LINE, never `fill:` wherever it
137
+ * appears: a node label may contain the word, and editing inside a label would
138
+ * corrupt the text the agent wrote.
139
+ */
140
+ function retintToTheme(source: string): string {
141
+ if (!source.includes('style') && !source.includes('classDef')) return source;
142
+
143
+ // One counter across the whole diagram, so the SAME authored fill maps to the
144
+ // same token everywhere — two nodes the model painted alike stay alike.
145
+ const assigned = new Map<string, FillPair>();
146
+
147
+ return source
148
+ .split('\n')
149
+ .map((line) => (COLOUR_DIRECTIVE.test(line) ? retint(line, assigned) : line))
150
+ .join('\n');
151
+ }
152
+
153
+ /**
154
+ * Rewrite one directive's literal colours onto theme tokens.
155
+ *
156
+ * A rewritten `fill` carries its partner `color` along, because the authored
157
+ * line rarely states one: the model picked a pale fill and left the text at the
158
+ * default, which is correct for its own palette and wrong for ours.
159
+ */
160
+ function retint(line: string, assigned: Map<string, FillPair>): string {
161
+ // Resolved from the FILL first, so an authored `color:` in the same directive
162
+ // becomes this fill's partner. Reading them in source order instead lets a
163
+ // `color:` that precedes — or simply arrives before the fill is known — draw
164
+ // the next tint in the cycle, which is a pair from a different node: measured
165
+ // as `fill="var(--primary)"` carrying `var(--secondary-foreground)`, grey
166
+ // text on saturated blue.
167
+ // Undefined when the directive sets no fill — a `linkStyle 0 stroke:#f00`
168
+ // colours an EDGE and has no box to pair with. Its stroke is still ours to
169
+ // take: left alone, a model's red arrow stays red in both themes, which was
170
+ // the one literal that survived the first version of this.
171
+ const pair = pairFor(line, assigned);
172
+
173
+ const rewritten = line.replace(LITERAL_COLOUR, (whole, property: string, value: string) => {
174
+ // `fill:none` and `stroke:transparent` are structural, not palette — a node
175
+ // deliberately left hollow must stay hollow.
176
+ if (value === 'none' || value === 'transparent') return whole;
177
+ if (property === 'stroke') return `${property}:${STROKE_TOKEN}`;
178
+ if (pair === undefined) return whole;
179
+ // An authored `color:` is honoured as the pair's text, not as its own
180
+ // choice — a literal there has the same contrast problem as a literal fill.
181
+ return `${property}:${property === 'color' ? pair.text : pair.fill}`;
182
+ });
183
+
184
+ if (pair === undefined) return rewritten;
185
+ return /(^|,)\s*color\s*:/.test(rewritten) ? rewritten : `${rewritten},color:${pair.text}`;
186
+ }
187
+
188
+ /**
189
+ * The token pair this directive's fill maps to, or undefined when it sets none.
190
+ *
191
+ * Keyed on the colour's HUE FAMILY, not its exact value. A model painting two
192
+ * peer nodes reaches for two neighbouring blues without meaning a distinction —
193
+ * measured on a live answer, `#4285F4` and `#5C9EFF` are hues 217 and 216, one
194
+ * degree apart and identical to a reader. Keyed on the literal, those became
195
+ * two different tokens and the two peer boxes rendered with different label
196
+ * colours, which is a difference the diagram never claimed.
197
+ *
198
+ * So near hues collapse to one pair, and genuinely different ones (green 120
199
+ * against yellow 51) still separate.
200
+ */
201
+ function pairFor(line: string, assigned: Map<string, FillPair>): FillPair | undefined {
202
+ const fill = new RegExp(LITERAL_COLOUR.source, 'g')
203
+ .exec(line.replace(/(stroke|color)\s*:/g, 'x:'))
204
+ ?.[2];
205
+ if (fill === undefined || fill === 'none' || fill === 'transparent') return undefined;
206
+
207
+ const key = hueFamily(fill);
208
+ const known = assigned.get(key);
209
+ if (known !== undefined) return known;
210
+
211
+ const pair = FILL_PAIRS[assigned.size % FILL_PAIRS.length] as FillPair;
212
+ assigned.set(key, pair);
213
+ return pair;
214
+ }
215
+
216
+ /** How far apart two hues must sit to read as different colours, in degrees. */
217
+ const HUE_BUCKET = 30;
218
+
219
+ /** Below this saturation a colour reads as grey, whatever its hue says. */
220
+ const GREY_SATURATION = 15;
221
+
222
+ /**
223
+ * A key that groups colours a reader would call the same.
224
+ *
225
+ * Hex only: a named colour or an `rgb()` would need a parser this module has no
226
+ * reason to carry, and falling back to the literal is correct for them — an
227
+ * exact repeat still groups, and only near-misses lose their grouping.
228
+ */
229
+ function hueFamily(colour: string): string {
230
+ const hex = /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.exec(colour.trim())?.[1];
231
+ if (hex === undefined) return colour.toLowerCase();
232
+
233
+ const full = hex.length === 3 ? [...hex].map((c) => c + c).join('') : hex;
234
+ const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255) as [
235
+ number,
236
+ number,
237
+ number,
238
+ ];
239
+
240
+ const max = Math.max(r, g, b);
241
+ const min = Math.min(r, g, b);
242
+ const span = max - min;
243
+ const lightness = (max + min) / 2;
244
+ const saturation = span === 0 ? 0 : span / (1 - Math.abs(2 * lightness - 1));
245
+ if (saturation * 100 < GREY_SATURATION) return 'grey';
246
+
247
+ let hue: number;
248
+ if (max === r) hue = ((g - b) / span) % 6;
249
+ else if (max === g) hue = (b - r) / span + 2;
250
+ else hue = (r - g) / span + 4;
251
+ hue *= 60;
252
+ if (hue < 0) hue += 360;
253
+
254
+ return `hue-${Math.round(hue / HUE_BUCKET)}`;
255
+ }
256
+
257
+ /**
258
+ * Accept the dialect models actually write.
259
+ *
260
+ * `graph TD;` is valid mermaid and the parser rejects it — it matches the
261
+ * header exactly and a trailing `;` misses. Semicolons ELSEWHERE are already
262
+ * fine (`A --> B;` parses), so the gap is one line wide and invisible from the
263
+ * source: a diagram written the ordinary way simply never draws.
264
+ *
265
+ * Worth normalising rather than reporting, because we are the ones teaching the
266
+ * dialect — `show_media`'s own description offers `'graph TD; A-->B'` as its
267
+ * example, so the agent is being shown a form its renderer refuses.
268
+ *
269
+ * The single-line form (`graph TD; A-->B`) splits: the header ends at the
270
+ * semicolon and the rest is a statement, which is what mermaid means by it.
271
+ */
272
+ function normalize(source: string): string {
273
+ const trimmed = source.trimStart();
274
+ const firstBreak = trimmed.indexOf('\n');
275
+ const header = firstBreak === -1 ? trimmed : trimmed.slice(0, firstBreak);
276
+ const rest = firstBreak === -1 ? '' : trimmed.slice(firstBreak);
277
+
278
+ const semicolon = header.indexOf(';');
279
+ if (semicolon === -1) return source;
280
+
281
+ const declaration = header.slice(0, semicolon).trimEnd();
282
+ const inlined = header.slice(semicolon + 1).trim();
283
+ return inlined === ''
284
+ ? `${declaration}${rest}`
285
+ : `${declaration}\n ${inlined}${rest}`;
286
+ }
287
+
288
+ /**
289
+ * Whether a diagram is finished enough to draw.
290
+ *
291
+ * The guard exists because the renderer does NOT throw on a partial source — it
292
+ * draws a plausible wrong picture. `graph TD\n A[Start] --> ` renders an
293
+ * arrow into nothing, and the next token changes it, so a streaming diagram
294
+ * would flicker through several confident-looking lies before settling.
295
+ *
296
+ * Same class of defect as a typeset math prefix, and the same cure: show the
297
+ * source until the shape is closed, then draw once.
298
+ *
299
+ * Balance is counted on the bracket pairs a mermaid node uses for its label.
300
+ * Quotes are ignored deliberately — a label may legitimately contain a lone
301
+ * bracket, and being wrong about a closed diagram costs a frame while being
302
+ * wrong about an open one costs the flicker this guard removes.
303
+ */
304
+ export function isDiagramComplete(source: string): boolean {
305
+ const trimmed = source.trimEnd();
306
+ if (trimmed === '') return false;
307
+
308
+ // A trailing edge (`-->`, `---`, `-.->`) means the next node has not arrived.
309
+ if (/(--+>?|-\.-+>?|==+>?)\s*\|?[^|]*$/.test(trimmed) && /[-=>]$/.test(trimmed)) {
310
+ return false;
311
+ }
312
+
313
+ for (const [open, close] of [
314
+ ['[', ']'],
315
+ ['(', ')'],
316
+ ['{', '}'],
317
+ ] as const) {
318
+ if (count(trimmed, open) !== count(trimmed, close)) return false;
319
+ }
320
+
321
+ return true;
322
+ }
323
+
324
+ function count(text: string, char: string): number {
325
+ let total = 0;
326
+ for (const candidate of text) if (candidate === char) total += 1;
327
+ return total;
328
+ }
@@ -0,0 +1,60 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * The package owns its copy, typed.
5
+ *
6
+ * `MediaCopy` is the schema of record: a missing or misspelled key is a `tsc`
7
+ * error rather than `undefined` rendered into a toolbar button. Copy used to
8
+ * arrive through a host i18n provider, which made the viewer unmountable
9
+ * without one — a story, a preview route and a test each had to stand a
10
+ * provider up to draw one image.
11
+ *
12
+ * A host that already translates passes `copy` to override any subset; keys it
13
+ * omits fall back to English.
14
+ */
15
+
16
+ export interface MediaCopy {
17
+ close: string;
18
+ diagram: string;
19
+ download: string;
20
+ failedToLoad: string;
21
+ fitToView: string;
22
+ flipHorizontal: string;
23
+ flipVertical: string;
24
+ fullscreen: string;
25
+ loading: string;
26
+ next: string;
27
+ noImage: string;
28
+ previous: string;
29
+ rotate: string;
30
+ zoomIn: string;
31
+ zoomOut: string;
32
+ }
33
+
34
+ export const DEFAULT_MEDIA_COPY: MediaCopy = {
35
+ close: 'Close',
36
+ diagram: 'Diagram',
37
+ download: 'Download',
38
+ failedToLoad: 'Failed to load',
39
+ fitToView: 'Fit to view',
40
+ flipHorizontal: 'Flip horizontal',
41
+ flipVertical: 'Flip vertical',
42
+ fullscreen: 'Fullscreen',
43
+ loading: 'Loading…',
44
+ next: 'Next',
45
+ noImage: 'No image',
46
+ previous: 'Previous',
47
+ rotate: 'Rotate',
48
+ zoomIn: 'Zoom in',
49
+ zoomOut: 'Zoom out',
50
+ };
51
+
52
+ /**
53
+ * Resolve the copy a component renders. Takes the host's partial override and
54
+ * fills the rest from the defaults, so a caller supplying one string does not
55
+ * blank the other fourteen.
56
+ */
57
+ export function useMediaCopy(copy?: Partial<MediaCopy>): MediaCopy {
58
+ if (!copy) return DEFAULT_MEDIA_COPY;
59
+ return { ...DEFAULT_MEDIA_COPY, ...copy };
60
+ }
@@ -0,0 +1,55 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * ImageInfo - Displays image dimensions badge
5
+ */
6
+
7
+ import { useEffect, useState } from 'react';
8
+ import { useImageCache } from '../../internal/media-cache';
9
+ import type { ImageInfoProps } from '../types';
10
+
11
+ // =============================================================================
12
+ // COMPONENT
13
+ // =============================================================================
14
+
15
+ export function ImageInfo({ src }: ImageInfoProps) {
16
+ const { getDimensions, cacheDimensions } = useImageCache();
17
+ const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null);
18
+
19
+ useEffect(() => {
20
+ // Check cache first
21
+ const cached = getDimensions(src);
22
+ if (cached) {
23
+ setDimensions({ w: cached.width, h: cached.height });
24
+ return;
25
+ }
26
+
27
+ // Clear stale dimensions while the new source resolves
28
+ setDimensions(null);
29
+
30
+ // Load and cache dimensions
31
+ let cancelled = false;
32
+ const img = new Image();
33
+ img.onload = () => {
34
+ if (cancelled) return;
35
+ const dims = { w: img.naturalWidth, h: img.naturalHeight };
36
+ setDimensions(dims);
37
+ cacheDimensions(src, { width: dims.w, height: dims.h });
38
+ };
39
+ img.src = src;
40
+
41
+ return () => {
42
+ cancelled = true;
43
+ img.onload = null;
44
+ img.src = '';
45
+ };
46
+ }, [src, getDimensions, cacheDimensions]);
47
+
48
+ if (!dimensions) return null;
49
+
50
+ return (
51
+ <div className="absolute top-3 right-3 z-10 px-2 py-1 bg-black/60 backdrop-blur-sm border border-white/10 rounded text-[10px] text-white/80 font-mono">
52
+ {dimensions.w} × {dimensions.h}
53
+ </div>
54
+ );
55
+ }
@@ -0,0 +1,167 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * ImageToolbar - Floating toolbar for image controls
5
+ */
6
+
7
+ import { useMemo, type ReactNode } from 'react';
8
+ import { ZoomIn, ZoomOut, RotateCw, FlipHorizontal, FlipVertical, Maximize2, Expand } from 'lucide-react';
9
+ import { useControls } from 'react-zoom-pan-pinch';
10
+ import {
11
+ DropdownMenu,
12
+ DropdownMenuContent,
13
+ DropdownMenuItem,
14
+ DropdownMenuTrigger,
15
+ Tooltip,
16
+ TooltipContent,
17
+ TooltipTrigger,
18
+ } from '@djangocfg/ui-core/components';
19
+ import { useMediaCopy } from '../../i18n';
20
+ import { cn } from '@djangocfg/ui-core/lib';
21
+ import { ZOOM_PRESETS } from '../utils';
22
+ import type { ImageToolbarProps } from '../types';
23
+
24
+ // =============================================================================
25
+ // COMPONENT
26
+ // =============================================================================
27
+
28
+ // The toolbar floats over the image, whose brightness is unknown and
29
+ // unrelated to the host theme. A theme-bound `bg-background` surface
30
+ // would clash with a dark photo (white slab) or a light photo in dark
31
+ // mode. A fixed dark-glass overlay with light icons stays readable on
32
+ // any content — the same model the gallery nav arrows and media
33
+ // players use.
34
+ const TB_BUTTON =
35
+ 'inline-flex items-center justify-center rounded-md text-white/90 ' +
36
+ 'transition-colors hover:bg-white/15 hover:text-white ' +
37
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70 ' +
38
+ 'disabled:opacity-40 disabled:pointer-events-none';
39
+
40
+ // Small helper so each icon button shares the same Tooltip wrapper +
41
+ // accessibility shape (visible tooltip on hover/focus, aria-label for AT).
42
+ function TbIconButton({
43
+ label,
44
+ onClick,
45
+ className,
46
+ children,
47
+ }: {
48
+ label: string;
49
+ onClick: () => void;
50
+ className?: string;
51
+ children: ReactNode;
52
+ }) {
53
+ return (
54
+ <Tooltip>
55
+ <TooltipTrigger asChild>
56
+ <button
57
+ type="button"
58
+ aria-label={label}
59
+ onClick={onClick}
60
+ className={cn(TB_BUTTON, 'h-7 w-7', className)}
61
+ >
62
+ {children}
63
+ </button>
64
+ </TooltipTrigger>
65
+ <TooltipContent side="top">{label}</TooltipContent>
66
+ </Tooltip>
67
+ );
68
+ }
69
+
70
+ export function ImageToolbar({
71
+ scale,
72
+ transform,
73
+ onRotate,
74
+ onFlipH,
75
+ onFlipV,
76
+ onZoomPreset,
77
+ onExpand,
78
+ copy,
79
+ }: ImageToolbarProps) {
80
+ const { zoomIn, zoomOut, resetTransform } = useControls();
81
+ const labels = useMediaCopy(copy);
82
+
83
+ const zoomLabel = useMemo(() => {
84
+ const percent = Math.round(scale * 100);
85
+ return `${percent}%`;
86
+ }, [scale]);
87
+
88
+ return (
89
+ <div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-0.5 bg-black/60 backdrop-blur-sm border border-white/10 rounded-lg p-1 shadow-lg">
90
+ {/* Zoom controls */}
91
+ <TbIconButton label={labels.zoomOut} onClick={() => zoomOut()}>
92
+ <ZoomOut className="h-3.5 w-3.5" />
93
+ </TbIconButton>
94
+
95
+ <DropdownMenu>
96
+ <DropdownMenuTrigger asChild>
97
+ <button
98
+ type="button"
99
+ className={cn(TB_BUTTON, 'h-7 px-2 min-w-[52px] font-mono text-xs')}
100
+ >
101
+ {zoomLabel}
102
+ </button>
103
+ </DropdownMenuTrigger>
104
+ <DropdownMenuContent align="center" className="min-w-[80px]">
105
+ {ZOOM_PRESETS.map((preset) => (
106
+ <DropdownMenuItem
107
+ key={preset.label}
108
+ onClick={() => onZoomPreset(preset.value)}
109
+ className="text-xs justify-center"
110
+ >
111
+ {preset.label}
112
+ </DropdownMenuItem>
113
+ ))}
114
+ </DropdownMenuContent>
115
+ </DropdownMenu>
116
+
117
+ <TbIconButton label={labels.zoomIn} onClick={() => zoomIn()}>
118
+ <ZoomIn className="h-3.5 w-3.5" />
119
+ </TbIconButton>
120
+
121
+ <div className="w-px h-4 bg-white/20 mx-1" />
122
+
123
+ {/* Fit to view */}
124
+ <TbIconButton label={labels.fitToView} onClick={() => resetTransform()}>
125
+ <Maximize2 className="h-3.5 w-3.5" />
126
+ </TbIconButton>
127
+
128
+ <div className="w-px h-4 bg-white/20 mx-1" />
129
+
130
+ {/* Transform controls */}
131
+ <TbIconButton
132
+ label={labels.flipHorizontal}
133
+ onClick={onFlipH}
134
+ className={transform.flipH ? 'bg-white/20 text-white' : undefined}
135
+ >
136
+ <FlipHorizontal className="h-3.5 w-3.5" />
137
+ </TbIconButton>
138
+
139
+ <TbIconButton
140
+ label={labels.flipVertical}
141
+ onClick={onFlipV}
142
+ className={transform.flipV ? 'bg-white/20 text-white' : undefined}
143
+ >
144
+ <FlipVertical className="h-3.5 w-3.5" />
145
+ </TbIconButton>
146
+
147
+ <TbIconButton label={labels.rotate} onClick={onRotate}>
148
+ <RotateCw className="h-3.5 w-3.5" />
149
+ </TbIconButton>
150
+
151
+ {transform.rotation !== 0 && (
152
+ <span className="text-[10px] text-white/60 font-mono pl-1">
153
+ {transform.rotation}°
154
+ </span>
155
+ )}
156
+
157
+ {onExpand && (
158
+ <>
159
+ <div className="w-px h-4 bg-white/20 mx-1" />
160
+ <TbIconButton label={labels.fullscreen} onClick={onExpand}>
161
+ <Expand className="h-3.5 w-3.5" />
162
+ </TbIconButton>
163
+ </>
164
+ )}
165
+ </div>
166
+ );
167
+ }