@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.
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/package.json +81 -0
- package/src/code/code.tsx +116 -0
- package/src/code/index.ts +10 -0
- package/src/diagram/diagram-source.tsx +29 -0
- package/src/diagram/diagram.tsx +64 -0
- package/src/diagram/index.ts +22 -0
- package/src/diagram/lazy.tsx +45 -0
- package/src/diagram/plugin.ts +62 -0
- package/src/diagram/render.ts +328 -0
- package/src/i18n/index.ts +60 -0
- package/src/image-viewer/components/ImageInfo.tsx +55 -0
- package/src/image-viewer/components/ImageToolbar.tsx +167 -0
- package/src/image-viewer/components/ImageViewer.tsx +508 -0
- package/src/image-viewer/components/LightboxChrome.tsx +157 -0
- package/src/image-viewer/components/MiddleEllipsis.tsx +46 -0
- package/src/image-viewer/components/index.ts +9 -0
- package/src/image-viewer/hooks/index.ts +12 -0
- package/src/image-viewer/hooks/useIdleChrome.ts +73 -0
- package/src/image-viewer/hooks/useImageLoading.ts +220 -0
- package/src/image-viewer/hooks/useImageTransform.ts +102 -0
- package/src/image-viewer/index.ts +65 -0
- package/src/image-viewer/lazy.tsx +33 -0
- package/src/image-viewer/types.ts +139 -0
- package/src/image-viewer/utils/constants.ts +90 -0
- package/src/image-viewer/utils/debug.ts +14 -0
- package/src/image-viewer/utils/index.ts +22 -0
- package/src/image-viewer/utils/lqip.ts +47 -0
- package/src/index.ts +10 -0
- package/src/internal/media-cache.ts +537 -0
- package/src/markdown/index.ts +6 -0
- package/src/markdown/markdown.tsx +113 -0
|
@@ -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
|
+
}
|