@vespercms/admin 0.0.0-stage → 0.1.0-next.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.
Files changed (207) hide show
  1. package/README.md +119 -2
  2. package/dist/BodyEditor-_mkh0BSQ.js +3047 -0
  3. package/dist/a11y/FieldNotes.d.ts +23 -0
  4. package/dist/a11y/NotesDrawer.d.ts +23 -0
  5. package/dist/a11y/pdf.d.ts +3 -0
  6. package/dist/a11y/run.d.ts +3 -0
  7. package/dist/a11y/useNotes.d.ts +28 -0
  8. package/dist/app/App.d.ts +2 -0
  9. package/dist/app/Screen.d.ts +88 -0
  10. package/dist/app/UserMenu.d.ts +7 -0
  11. package/dist/app/api.d.ts +75 -0
  12. package/dist/app/appearance.d.ts +10 -0
  13. package/dist/app/browser-api.d.ts +17 -0
  14. package/dist/app/collection-icon.d.ts +12 -0
  15. package/dist/app/context.d.ts +23 -0
  16. package/dist/app/entries-changed.d.ts +11 -0
  17. package/dist/app/file-url.d.ts +13 -0
  18. package/dist/app/format.d.ts +4 -0
  19. package/dist/app/idb.d.ts +7 -0
  20. package/dist/app/load.d.ts +16 -0
  21. package/dist/app/media.d.ts +5 -0
  22. package/dist/app/navigation.d.ts +4 -0
  23. package/dist/app/pane-context.d.ts +19 -0
  24. package/dist/app/panes.d.ts +14 -0
  25. package/dist/app/router.d.ts +62 -0
  26. package/dist/body/BodyEditor.d.ts +42 -0
  27. package/dist/body/TableView.d.ts +2 -0
  28. package/dist/body/catalog.d.ts +47 -0
  29. package/dist/body/checks.d.ts +15 -0
  30. package/dist/body/chips.d.ts +31 -0
  31. package/dist/body/commands.d.ts +31 -0
  32. package/dist/body/document.d.ts +42 -0
  33. package/dist/body/insert.d.ts +69 -0
  34. package/dist/body/nodes.d.ts +38 -0
  35. package/dist/body/reveal.d.ts +13 -0
  36. package/dist/body/scroll.d.ts +16 -0
  37. package/dist/body/settings.d.ts +19 -0
  38. package/dist/body/shortcuts.d.ts +13 -0
  39. package/dist/body/table.d.ts +31 -0
  40. package/dist/body/ui.d.ts +30 -0
  41. package/dist/body/views.d.ts +9 -0
  42. package/dist/checks-C-0xDu-I.js +57 -0
  43. package/dist/cloud/CloudGate.d.ts +14 -0
  44. package/dist/cloud/access.d.ts +14 -0
  45. package/dist/cloud/changes.d.ts +26 -0
  46. package/dist/cloud/connection.d.ts +100 -0
  47. package/dist/cloud/editor.d.ts +44 -0
  48. package/dist/cloud/session.d.ts +62 -0
  49. package/dist/cloud/storage.d.ts +45 -0
  50. package/dist/editor/Field.d.ts +24 -0
  51. package/dist/editor/PagePicker.d.ts +20 -0
  52. package/dist/editor/arrange.d.ts +13 -0
  53. package/dist/editor/context.d.ts +23 -0
  54. package/dist/editor/copy.d.ts +9 -0
  55. package/dist/editor/reveal.d.ts +7 -0
  56. package/dist/editor/target.d.ts +21 -0
  57. package/dist/editor/values.d.ts +18 -0
  58. package/dist/geo-map-CT3OGD7O.js +110 -0
  59. package/dist/github/GitHubGate.d.ts +15 -0
  60. package/dist/github/api.d.ts +20 -0
  61. package/dist/github/cache.d.ts +2 -0
  62. package/dist/github/session.d.ts +35 -0
  63. package/dist/index.d.ts +3 -0
  64. package/dist/markdown-4fVeej3c.js +211 -0
  65. package/dist/media/MediaPicker.d.ts +28 -0
  66. package/dist/media/file-icon.d.ts +3 -0
  67. package/dist/media/formats.d.ts +23 -0
  68. package/dist/media/hygiene.d.ts +37 -0
  69. package/dist/media/upload.d.ts +31 -0
  70. package/dist/mount.d.ts +3 -0
  71. package/dist/pdf-m5s_uXJ3.js +24 -0
  72. package/dist/run-y4XRkg17.js +7 -0
  73. package/dist/schedule/ScheduleDialog.d.ts +28 -0
  74. package/dist/schedule/ScheduleSummary.d.ts +12 -0
  75. package/dist/schedule/ScheduledView.d.ts +8 -0
  76. package/dist/schedule/cloud.d.ts +21 -0
  77. package/dist/schedule/now.d.ts +5 -0
  78. package/dist/schedule/times.d.ts +35 -0
  79. package/dist/screens/EntryEditor.d.ts +13 -0
  80. package/dist/screens/EntryList.d.ts +16 -0
  81. package/dist/screens/Home.d.ts +6 -0
  82. package/dist/screens/ListPane.d.ts +6 -0
  83. package/dist/screens/MediaBrowser.d.ts +7 -0
  84. package/dist/screens/NotFound.d.ts +3 -0
  85. package/dist/screens/PageTree.d.ts +10 -0
  86. package/dist/screens/Redirects.d.ts +1 -0
  87. package/dist/screens/Unavailable.d.ts +8 -0
  88. package/dist/screens/status.d.ts +36 -0
  89. package/dist/shortcuts-CarKeDqp.js +4666 -0
  90. package/dist/studio.css +3 -0
  91. package/dist/studio.js +6449 -0
  92. package/dist/types.d.ts +76 -0
  93. package/dist/widgets/basic.d.ts +25 -0
  94. package/dist/widgets/blank-date.d.ts +8 -0
  95. package/dist/widgets/crops.d.ts +43 -0
  96. package/dist/widgets/focal-point.d.ts +15 -0
  97. package/dist/widgets/geo-map.d.ts +10 -0
  98. package/dist/widgets/geo.d.ts +16 -0
  99. package/dist/widgets/group.d.ts +41 -0
  100. package/dist/widgets/icon.d.ts +9 -0
  101. package/dist/widgets/image.d.ts +16 -0
  102. package/dist/widgets/markdown-lazy.d.ts +2 -0
  103. package/dist/widgets/markdown.d.ts +2 -0
  104. package/dist/widgets/reference.d.ts +12 -0
  105. package/dist/widgets/registry.d.ts +32 -0
  106. package/dist/widgets/special.d.ts +20 -0
  107. package/dist/widgets/union.d.ts +36 -0
  108. package/package.json +63 -4
  109. package/src/a11y/FieldNotes.tsx +56 -0
  110. package/src/a11y/NotesDrawer.tsx +81 -0
  111. package/src/a11y/pdf.ts +45 -0
  112. package/src/a11y/run.ts +12 -0
  113. package/src/a11y/useNotes.ts +139 -0
  114. package/src/app/App.tsx +156 -0
  115. package/src/app/Screen.tsx +416 -0
  116. package/src/app/UserMenu.tsx +95 -0
  117. package/src/app/api.ts +176 -0
  118. package/src/app/appearance.ts +54 -0
  119. package/src/app/browser-api.ts +173 -0
  120. package/src/app/collection-icon.ts +15 -0
  121. package/src/app/context.tsx +32 -0
  122. package/src/app/entries-changed.ts +48 -0
  123. package/src/app/file-url.tsx +44 -0
  124. package/src/app/format.ts +39 -0
  125. package/src/app/idb.ts +82 -0
  126. package/src/app/load.ts +40 -0
  127. package/src/app/media.ts +20 -0
  128. package/src/app/navigation.ts +4 -0
  129. package/src/app/pane-context.ts +25 -0
  130. package/src/app/panes.tsx +513 -0
  131. package/src/app/router.ts +158 -0
  132. package/src/body/BodyEditor.tsx +1139 -0
  133. package/src/body/TableView.tsx +700 -0
  134. package/src/body/catalog.ts +198 -0
  135. package/src/body/checks.ts +83 -0
  136. package/src/body/chips.ts +150 -0
  137. package/src/body/commands.ts +191 -0
  138. package/src/body/document.ts +388 -0
  139. package/src/body/insert.tsx +301 -0
  140. package/src/body/nodes.ts +287 -0
  141. package/src/body/reveal.ts +93 -0
  142. package/src/body/scroll.ts +53 -0
  143. package/src/body/settings.tsx +510 -0
  144. package/src/body/shortcuts.ts +31 -0
  145. package/src/body/table.ts +192 -0
  146. package/src/body/ui.ts +77 -0
  147. package/src/body/views.tsx +268 -0
  148. package/src/build.ts +6 -0
  149. package/src/cloud/CloudGate.tsx +245 -0
  150. package/src/cloud/access.ts +52 -0
  151. package/src/cloud/changes.tsx +474 -0
  152. package/src/cloud/connection.ts +281 -0
  153. package/src/cloud/editor.tsx +250 -0
  154. package/src/cloud/session.ts +289 -0
  155. package/src/cloud/storage.ts +212 -0
  156. package/src/editor/Field.tsx +182 -0
  157. package/src/editor/PagePicker.tsx +102 -0
  158. package/src/editor/arrange.ts +98 -0
  159. package/src/editor/context.tsx +31 -0
  160. package/src/editor/copy.ts +30 -0
  161. package/src/editor/reveal.ts +38 -0
  162. package/src/editor/target.ts +104 -0
  163. package/src/editor/values.ts +71 -0
  164. package/src/github/GitHubGate.tsx +298 -0
  165. package/src/github/api.ts +81 -0
  166. package/src/github/cache.ts +64 -0
  167. package/src/github/session.ts +146 -0
  168. package/src/index.ts +10 -0
  169. package/src/media/MediaPicker.tsx +302 -0
  170. package/src/media/file-icon.ts +17 -0
  171. package/src/media/formats.ts +262 -0
  172. package/src/media/hygiene.ts +220 -0
  173. package/src/media/upload.ts +47 -0
  174. package/src/mount.tsx +20 -0
  175. package/src/schedule/ScheduleDialog.tsx +303 -0
  176. package/src/schedule/ScheduleSummary.tsx +59 -0
  177. package/src/schedule/ScheduledView.tsx +294 -0
  178. package/src/schedule/cloud.ts +66 -0
  179. package/src/schedule/now.ts +18 -0
  180. package/src/schedule/times.ts +122 -0
  181. package/src/screens/EntryEditor.tsx +1817 -0
  182. package/src/screens/EntryList.tsx +272 -0
  183. package/src/screens/Home.tsx +131 -0
  184. package/src/screens/ListPane.tsx +477 -0
  185. package/src/screens/MediaBrowser.tsx +253 -0
  186. package/src/screens/NotFound.tsx +19 -0
  187. package/src/screens/PageTree.tsx +738 -0
  188. package/src/screens/Redirects.tsx +522 -0
  189. package/src/screens/Unavailable.tsx +27 -0
  190. package/src/screens/status.tsx +104 -0
  191. package/src/studio.css +168 -0
  192. package/src/types.ts +82 -0
  193. package/src/widgets/basic.tsx +315 -0
  194. package/src/widgets/blank-date.ts +32 -0
  195. package/src/widgets/crops.tsx +399 -0
  196. package/src/widgets/focal-point.tsx +240 -0
  197. package/src/widgets/geo-map.tsx +160 -0
  198. package/src/widgets/geo.tsx +170 -0
  199. package/src/widgets/group.tsx +827 -0
  200. package/src/widgets/icon.tsx +47 -0
  201. package/src/widgets/image.tsx +407 -0
  202. package/src/widgets/markdown-lazy.tsx +22 -0
  203. package/src/widgets/markdown.tsx +267 -0
  204. package/src/widgets/reference.tsx +196 -0
  205. package/src/widgets/registry.ts +104 -0
  206. package/src/widgets/special.tsx +290 -0
  207. package/src/widgets/union.tsx +233 -0
@@ -0,0 +1,399 @@
1
+ import {
2
+ cropForRatio,
3
+ dragCorner,
4
+ humanize,
5
+ moveRect,
6
+ ratioLabel,
7
+ resizeRect,
8
+ roundRect,
9
+ type Corner,
10
+ type CropRect,
11
+ type FocalPoint,
12
+ type ImageSize,
13
+ type ImageValue,
14
+ } from '@vespercms/core';
15
+ import { Button, Dialog, cx, focusRing } from '@vespercms/ui';
16
+ import { useLayoutEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';
17
+
18
+ /*
19
+ * Crop previews and the crop dialog (spec §6.6.4). Each preview is the image
20
+ * cropped by CSS to one ratio, from the same `cropForRatio` the site's <Img>
21
+ * uses, so it follows the focal point live and shows exactly what the site
22
+ * renders. "Adjust crop" opens a dialog with a fixed-ratio box: drag it,
23
+ * drag a corner, or focus it and use the arrow keys (Shift resizes).
24
+ */
25
+
26
+ export type Ratio = { name: string; ratio: number };
27
+
28
+ /** A ratio's name for people: "Wide", or "Social image" for the built-in og. */
29
+ export function ratioName(name: string): string {
30
+ return name === 'og' ? 'Social image' : humanize(name);
31
+ }
32
+
33
+ const percent = (n: number) => Math.round(n * 100);
34
+
35
+ /** The image positioned inside a box of the crop's shape so only the crop shows. */
36
+ export function cropStyle(rect: CropRect) {
37
+ return {
38
+ width: `${String(100 / rect.w)}%`,
39
+ height: `${String(100 / rect.h)}%`,
40
+ left: `${String((-rect.x / rect.w) * 100)}%`,
41
+ top: `${String((-rect.y / rect.h) * 100)}%`,
42
+ };
43
+ }
44
+
45
+ export type CropPreviewsProps = {
46
+ id: string;
47
+ src: string;
48
+ /** The image's natural size; previews wait for it. */
49
+ size: ImageSize | null;
50
+ value: ImageValue;
51
+ ratios: Ratio[];
52
+ /** The field's crops after a change: one applied, or one reset to the focal point. */
53
+ onChange: (crops: Record<string, CropRect>) => void;
54
+ disabled: boolean;
55
+ };
56
+
57
+ export function CropPreviews({
58
+ id,
59
+ src,
60
+ size,
61
+ value,
62
+ ratios,
63
+ onChange,
64
+ disabled,
65
+ }: CropPreviewsProps) {
66
+ const [open, setOpen] = useState<Ratio | null>(null);
67
+ const [announcement, setAnnouncement] = useState('');
68
+ if (!size) {
69
+ return (
70
+ <p className="text-small text-content-muted">Loading the image for the crop previews…</p>
71
+ );
72
+ }
73
+ const buttonId = (name: string) => `${id}-crop-${name}`;
74
+ const afterDialog = (name: string) => {
75
+ requestAnimationFrame(() => {
76
+ document.getElementById(buttonId(name))?.focus();
77
+ });
78
+ };
79
+
80
+ return (
81
+ <div className="flex flex-col gap-2">
82
+ <ul aria-label="Crops" className="flex flex-wrap items-end gap-4">
83
+ {ratios.map(({ name, ratio }) => {
84
+ const { rect, custom } = cropForRatio(value, name, ratio, size);
85
+ const label = ratioName(name);
86
+ return (
87
+ <li key={name}>
88
+ <figure className="flex flex-col gap-1">
89
+ <div
90
+ className="relative h-24 self-start overflow-hidden rounded-sm border border-border"
91
+ style={{ aspectRatio: String(ratio) }}
92
+ >
93
+ <img
94
+ src={src}
95
+ alt=""
96
+ draggable={false}
97
+ className="absolute max-w-none"
98
+ style={cropStyle(rect)}
99
+ />
100
+ </div>
101
+ <figcaption className="text-small text-content">
102
+ {label} · {ratioLabel(ratio)}
103
+ {custom && <span className="text-content-muted"> · Custom crop</span>}
104
+ </figcaption>
105
+ </figure>
106
+ <Button
107
+ id={buttonId(name)}
108
+ variant="quiet"
109
+ isDisabled={disabled}
110
+ className="-ml-4"
111
+ onPress={() => {
112
+ setOpen({ name, ratio });
113
+ }}
114
+ >
115
+ Adjust crop <span className="sr-only">for {label}</span>
116
+ </Button>
117
+ </li>
118
+ );
119
+ })}
120
+ </ul>
121
+ <p role="status" className="sr-only">
122
+ {announcement}
123
+ </p>
124
+ {open && (
125
+ <CropDialog
126
+ src={src}
127
+ size={size}
128
+ name={open.name}
129
+ ratio={open.ratio}
130
+ start={cropForRatio(value, open.name, open.ratio, size)}
131
+ onClose={() => {
132
+ setOpen(null);
133
+ afterDialog(open.name);
134
+ }}
135
+ onApply={(rect) => {
136
+ onChange({ ...value.crops, [open.name]: roundRect(rect) });
137
+ setAnnouncement(`Custom crop applied to ${ratioName(open.name)}.`);
138
+ }}
139
+ onReset={() => {
140
+ const { [open.name]: _removed, ...rest } = value.crops;
141
+ onChange(rest);
142
+ setAnnouncement(`${ratioName(open.name)} crop reset to the focal point.`);
143
+ }}
144
+ />
145
+ )}
146
+ </div>
147
+ );
148
+ }
149
+
150
+ /** "40% from the left, 10% from the top, 45% of the width", for the crop box. */
151
+ export function describeCrop(rect: CropRect): string {
152
+ return `${String(percent(rect.x))}% from the left, ${String(percent(rect.y))}% from the top, ${String(percent(rect.w))}% of the width`;
153
+ }
154
+
155
+ const MOVES: Record<string, [number, number]> = {
156
+ ArrowLeft: [-1, 0],
157
+ ArrowRight: [1, 0],
158
+ ArrowUp: [0, -1],
159
+ ArrowDown: [0, 1],
160
+ };
161
+
162
+ /** Shift+→ and Shift+↓ grow the box, Shift+← and Shift+↑ shrink it. */
163
+ const GROWS: Record<string, number> = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
164
+
165
+ const CORNERS: { corner: Corner; className: string }[] = [
166
+ { corner: 'top-left', className: '-left-1.5 -top-1.5 cursor-nwse-resize' },
167
+ { corner: 'top-right', className: '-right-1.5 -top-1.5 cursor-nesw-resize' },
168
+ { corner: 'bottom-left', className: '-bottom-1.5 -left-1.5 cursor-nesw-resize' },
169
+ { corner: 'bottom-right', className: '-bottom-1.5 -right-1.5 cursor-nwse-resize' },
170
+ ];
171
+
172
+ type Drag =
173
+ | { kind: 'move'; from: FocalPoint; start: CropRect }
174
+ | { kind: 'corner'; corner: Corner; start: CropRect };
175
+
176
+ export type CropDialogProps = {
177
+ src: string;
178
+ size: ImageSize;
179
+ name: string;
180
+ ratio: number;
181
+ /** Where the box starts: the custom crop, or the focal point's. */
182
+ start: { rect: CropRect; custom: boolean };
183
+ onClose: () => void;
184
+ onApply: (rect: CropRect) => void;
185
+ onReset: () => void;
186
+ };
187
+
188
+ export function CropDialog({
189
+ src,
190
+ size,
191
+ name,
192
+ ratio,
193
+ start,
194
+ onClose,
195
+ onApply,
196
+ onReset,
197
+ }: CropDialogProps) {
198
+ const [rect, setRect] = useState(start.rect);
199
+ const [touched, setTouched] = useState(false);
200
+ const [announcement, setAnnouncement] = useState('');
201
+ const image = useRef<HTMLImageElement>(null);
202
+ const box = useRef<HTMLDivElement>(null);
203
+ const drag = useRef<Drag | null>(null);
204
+ const label = `${ratioName(name)} (${ratioLabel(ratio)})`;
205
+
206
+ // The box is what the dialog is for, so the keys work at once. A layout
207
+ // effect runs before React Aria's dialog effect, which then finds focus
208
+ // inside and leaves it. (React's autoFocus only works on form controls.)
209
+ useLayoutEffect(() => {
210
+ box.current?.focus();
211
+ }, []);
212
+
213
+ const change = (next: CropRect, announce: boolean) => {
214
+ setRect(next);
215
+ setTouched(true);
216
+ if (announce) setAnnouncement(`Crop ${describeCrop(next)}.`);
217
+ };
218
+ const apply = (close: () => void) => {
219
+ // Untouched over a ratio with no custom crop: nothing to write.
220
+ if (touched || start.custom) onApply(rect);
221
+ close();
222
+ };
223
+
224
+ const pointAt = (event: PointerEvent): FocalPoint | null => {
225
+ const box = image.current?.getBoundingClientRect();
226
+ if (!box || box.width === 0 || box.height === 0) return null;
227
+ return { x: (event.clientX - box.left) / box.width, y: (event.clientY - box.top) / box.height };
228
+ };
229
+ const onPointerDown = (event: PointerEvent<HTMLElement>) => {
230
+ if (event.button !== 0) return;
231
+ const corner = (event.target as HTMLElement).dataset.corner as Corner | undefined;
232
+ const from = pointAt(event);
233
+ if (!from) return;
234
+ event.currentTarget.setPointerCapture(event.pointerId);
235
+ drag.current = corner
236
+ ? { kind: 'corner', corner, start: rect }
237
+ : { kind: 'move', from, start: rect };
238
+ };
239
+ const onPointerMove = (event: PointerEvent<HTMLElement>) => {
240
+ const current = drag.current;
241
+ const at = current ? pointAt(event) : null;
242
+ if (!current || !at) return;
243
+ change(
244
+ current.kind === 'move'
245
+ ? moveRect(current.start, at.x - current.from.x, at.y - current.from.y)
246
+ : dragCorner(current.start, current.corner, at, ratio, size),
247
+ false,
248
+ );
249
+ };
250
+ const onPointerUp = () => {
251
+ if (!drag.current) return;
252
+ drag.current = null;
253
+ setAnnouncement(`Crop ${describeCrop(rect)}.`);
254
+ };
255
+
256
+ return (
257
+ <Dialog
258
+ title={`Adjust the ${ratioName(name)} crop`}
259
+ size="wide"
260
+ isOpen
261
+ onOpenChange={(isOpen) => {
262
+ if (!isOpen) onClose();
263
+ }}
264
+ >
265
+ {(close) => {
266
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
267
+ if (event.key === 'Enter') {
268
+ event.preventDefault();
269
+ apply(close);
270
+ return;
271
+ }
272
+ const move = MOVES[event.key];
273
+ if (!move || event.altKey || event.ctrlKey || event.metaKey) return;
274
+ event.preventDefault();
275
+ change(
276
+ event.shiftKey
277
+ ? resizeRect(rect, rect.w + (GROWS[event.key] ?? 0) * 0.01, ratio, size)
278
+ : moveRect(rect, move[0] * 0.01, move[1] * 0.01),
279
+ true,
280
+ );
281
+ };
282
+ return (
283
+ <>
284
+ <p id="vesper-crop-help" className="text-small text-content-muted">
285
+ Drag the box to move it, or a corner to resize it. With the box focused, the arrow
286
+ keys move it, Shift and the arrow keys resize it, Enter applies and Escape cancels.
287
+ </p>
288
+ <div className="relative mx-auto w-fit max-w-full touch-none select-none">
289
+ <img
290
+ ref={image}
291
+ src={src}
292
+ alt=""
293
+ draggable={false}
294
+ className="block max-h-[60vh] max-w-full"
295
+ />
296
+ {/* The image outside the crop, dimmed. */}
297
+ <span
298
+ aria-hidden="true"
299
+ className="absolute inset-x-0 top-0 bg-content/50"
300
+ style={{ height: `${String(rect.y * 100)}%` }}
301
+ />
302
+ <span
303
+ aria-hidden="true"
304
+ className="absolute inset-x-0 bottom-0 bg-content/50"
305
+ style={{ height: `${String((1 - rect.y - rect.h) * 100)}%` }}
306
+ />
307
+ <span
308
+ aria-hidden="true"
309
+ className="absolute left-0 bg-content/50"
310
+ style={{
311
+ top: `${String(rect.y * 100)}%`,
312
+ height: `${String(rect.h * 100)}%`,
313
+ width: `${String(rect.x * 100)}%`,
314
+ }}
315
+ />
316
+ <span
317
+ aria-hidden="true"
318
+ className="absolute right-0 bg-content/50"
319
+ style={{
320
+ top: `${String(rect.y * 100)}%`,
321
+ height: `${String(rect.h * 100)}%`,
322
+ width: `${String((1 - rect.x - rect.w) * 100)}%`,
323
+ }}
324
+ />
325
+ {/*
326
+ A two-way box has no ARIA widget role of its own. `application`
327
+ makes screen readers pass the arrow keys through to it, which
328
+ its keyboard model needs (DECISIONS.md, "The crop box").
329
+ */}
330
+ {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
331
+ <div
332
+ ref={box}
333
+ role="application"
334
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
335
+ tabIndex={0}
336
+ aria-label={`Crop box for ${label}`}
337
+ aria-describedby="vesper-crop-position vesper-crop-help"
338
+ data-crop-box=""
339
+ onKeyDown={onKeyDown}
340
+ onPointerDown={onPointerDown}
341
+ onPointerMove={onPointerMove}
342
+ onPointerUp={onPointerUp}
343
+ className={cx(
344
+ 'absolute cursor-move border border-on-accent outline-offset-0',
345
+ focusRing,
346
+ )}
347
+ style={{
348
+ left: `${String(rect.x * 100)}%`,
349
+ top: `${String(rect.y * 100)}%`,
350
+ width: `${String(rect.w * 100)}%`,
351
+ height: `${String(rect.h * 100)}%`,
352
+ }}
353
+ >
354
+ <span aria-hidden="true" className="absolute inset-0 border border-content/60" />
355
+ {CORNERS.map(({ corner, className }) => (
356
+ <span
357
+ key={corner}
358
+ aria-hidden="true"
359
+ data-corner={corner}
360
+ className={cx('absolute size-3 border border-content bg-on-accent', className)}
361
+ />
362
+ ))}
363
+ </div>
364
+ </div>
365
+ <p id="vesper-crop-position" className="sr-only">
366
+ {describeCrop(rect)}
367
+ </p>
368
+ <p role="status" className="sr-only">
369
+ {announcement}
370
+ </p>
371
+ <div className="flex flex-wrap justify-end gap-2">
372
+ {start.custom && (
373
+ <Button
374
+ onPress={() => {
375
+ onReset();
376
+ close();
377
+ }}
378
+ >
379
+ Reset to focal point
380
+ </Button>
381
+ )}
382
+ <Button variant="quiet" onPress={close}>
383
+ Cancel
384
+ </Button>
385
+ <Button
386
+ variant="primary"
387
+ onPress={() => {
388
+ apply(close);
389
+ }}
390
+ >
391
+ Apply crop
392
+ </Button>
393
+ </div>
394
+ </>
395
+ );
396
+ }}
397
+ </Dialog>
398
+ );
399
+ }
@@ -0,0 +1,240 @@
1
+ import { CENTER, roundFocus, type FocalPoint, type ImageSize } from '@vespercms/core';
2
+ import { Button, cx, focusRing } from '@vespercms/ui';
3
+ import { useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';
4
+ import { inputClass } from './basic.tsx';
5
+
6
+ /*
7
+ * The focal point (spec §6.6.4): what must stay in view when the image is
8
+ * cropped. The image itself is one button: a click or tap sets the point,
9
+ * the marker can be dragged, and with focus on it the arrow keys move the
10
+ * point 1% (Shift for 10%). The "Across" and "Down" number fields beside it
11
+ * set the point without seeing or pointing, and every move made with the
12
+ * keys or the pointer is announced politely.
13
+ */
14
+
15
+ const percent = (n: number) => Math.round(n * 100);
16
+
17
+ /** "62% across, 35% down", the words the control and the announcements use. */
18
+ export function describeFocus(focus: FocalPoint | undefined): string {
19
+ const point = focus ?? CENTER;
20
+ return `${String(percent(point.x))}% across, ${String(percent(point.y))}% down`;
21
+ }
22
+
23
+ const STEPS: Record<string, [number, number]> = {
24
+ ArrowLeft: [-1, 0],
25
+ ArrowRight: [1, 0],
26
+ ArrowUp: [0, -1],
27
+ ArrowDown: [0, 1],
28
+ };
29
+
30
+ export type FocalPointControlProps = {
31
+ id: string;
32
+ /** The image's URL. */
33
+ src: string;
34
+ focus: FocalPoint | undefined;
35
+ /** A new focal point, or undefined to go back to the centre. */
36
+ onChange: (focus: FocalPoint | undefined) => void;
37
+ /** The image's natural size, once it has loaded (for the crop previews). */
38
+ onImageSize?: (size: ImageSize) => void;
39
+ disabled: boolean;
40
+ };
41
+
42
+ export function FocalPointControl({
43
+ id,
44
+ src,
45
+ focus,
46
+ onChange,
47
+ onImageSize,
48
+ disabled,
49
+ }: FocalPointControlProps) {
50
+ const image = useRef<HTMLImageElement>(null);
51
+ const dragging = useRef(false);
52
+ const [announcement, setAnnouncement] = useState('');
53
+ const point = focus ?? CENTER;
54
+
55
+ const set = (next: FocalPoint, announce: boolean) => {
56
+ const rounded = roundFocus(next);
57
+ onChange(rounded);
58
+ if (announce) setAnnouncement(`Focal point ${describeFocus(rounded)}.`);
59
+ };
60
+
61
+ /** The pointer's place on the image, as fractions of it. */
62
+ const pointAt = (clientX: number, clientY: number): FocalPoint | null => {
63
+ const box = image.current?.getBoundingClientRect();
64
+ if (!box || box.width === 0 || box.height === 0) return null;
65
+ return { x: (clientX - box.left) / box.width, y: (clientY - box.top) / box.height };
66
+ };
67
+
68
+ const onPointerDown = (event: PointerEvent<HTMLButtonElement>) => {
69
+ if (disabled || event.button !== 0) return;
70
+ const next = pointAt(event.clientX, event.clientY);
71
+ if (!next) return;
72
+ dragging.current = true;
73
+ event.currentTarget.setPointerCapture(event.pointerId);
74
+ set(next, false);
75
+ };
76
+ const onPointerMove = (event: PointerEvent<HTMLButtonElement>) => {
77
+ if (!dragging.current) return;
78
+ const next = pointAt(event.clientX, event.clientY);
79
+ if (next) set(next, false);
80
+ };
81
+ const onPointerUp = (event: PointerEvent<HTMLButtonElement>) => {
82
+ if (!dragging.current) return;
83
+ dragging.current = false;
84
+ const next = pointAt(event.clientX, event.clientY);
85
+ if (next) set(next, true);
86
+ };
87
+
88
+ const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
89
+ const step = STEPS[event.key];
90
+ if (!step || disabled || event.altKey || event.ctrlKey || event.metaKey) return;
91
+ event.preventDefault();
92
+ const by = event.shiftKey ? 0.1 : 0.01;
93
+ set({ x: point.x + step[0] * by, y: point.y + step[1] * by }, true);
94
+ };
95
+
96
+ return (
97
+ <div className="flex flex-col gap-3">
98
+ <p id={`${id}-focus-help`} className="text-small text-content-muted">
99
+ The focal point stays in view when the image is cropped. Click the image or drag the marker;
100
+ or focus the image and use the arrow keys (Shift moves 10%).
101
+ </p>
102
+ <div className="flex flex-wrap items-start gap-4">
103
+ <button
104
+ type="button"
105
+ id={`${id}-focus`}
106
+ aria-label="Focal point"
107
+ aria-describedby={`${id}-focus-position ${id}-focus-help`}
108
+ disabled={disabled}
109
+ onPointerDown={onPointerDown}
110
+ onPointerMove={onPointerMove}
111
+ onPointerUp={onPointerUp}
112
+ onKeyDown={onKeyDown}
113
+ className={cx(
114
+ 'relative max-w-full cursor-crosshair touch-none rounded-md disabled:cursor-not-allowed',
115
+ focusRing,
116
+ )}
117
+ >
118
+ <img
119
+ ref={image}
120
+ src={src}
121
+ alt=""
122
+ draggable={false}
123
+ onLoad={(event) => {
124
+ const { naturalWidth: width, naturalHeight: height } = event.currentTarget;
125
+ if (width > 0 && height > 0) onImageSize?.({ width, height });
126
+ }}
127
+ className="block max-h-64 max-w-full rounded-md border border-border"
128
+ />
129
+ <span
130
+ aria-hidden="true"
131
+ data-focal-marker=""
132
+ className="pointer-events-none absolute -ml-3 -mt-3 size-6 rounded-full border border-on-accent bg-accent/70"
133
+ style={{ left: `${String(point.x * 100)}%`, top: `${String(point.y * 100)}%` }}
134
+ />
135
+ </button>
136
+ <div className="flex flex-col gap-3">
137
+ <PercentField
138
+ id={`${id}-focus-x`}
139
+ label="Across (%)"
140
+ hint="0 is the left edge, 100 the right"
141
+ value={focus ? percent(focus.x) : undefined}
142
+ placeholder={percent(CENTER.x)}
143
+ disabled={disabled}
144
+ onChange={(n) => {
145
+ set({ x: n / 100, y: point.y }, false);
146
+ }}
147
+ />
148
+ <PercentField
149
+ id={`${id}-focus-y`}
150
+ label="Down (%)"
151
+ hint="0 is the top, 100 the bottom"
152
+ value={focus ? percent(focus.y) : undefined}
153
+ placeholder={percent(CENTER.y)}
154
+ disabled={disabled}
155
+ onChange={(n) => {
156
+ set({ x: point.x, y: n / 100 }, false);
157
+ }}
158
+ />
159
+ {focus && (
160
+ <div>
161
+ <Button
162
+ variant="quiet"
163
+ isDisabled={disabled}
164
+ onPress={() => {
165
+ onChange(undefined);
166
+ setAnnouncement('Focal point back to the center.');
167
+ }}
168
+ >
169
+ Use the center
170
+ </Button>
171
+ </div>
172
+ )}
173
+ </div>
174
+ </div>
175
+ <p id={`${id}-focus-position`} className="sr-only">
176
+ {focus ? describeFocus(focus) : `Not set: the center, ${describeFocus(undefined)}`}
177
+ </p>
178
+ <p role="status" className="sr-only">
179
+ {announcement}
180
+ </p>
181
+ </div>
182
+ );
183
+ }
184
+
185
+ /** A whole percentage, 0 to 100. Text state lets "6" be typed on the way to "62". */
186
+ function PercentField({
187
+ id,
188
+ label,
189
+ hint,
190
+ value,
191
+ placeholder,
192
+ disabled,
193
+ onChange,
194
+ }: {
195
+ id: string;
196
+ label: string;
197
+ hint: string;
198
+ value: number | undefined;
199
+ placeholder: number;
200
+ disabled: boolean;
201
+ onChange: (value: number) => void;
202
+ }) {
203
+ const [text, setText] = useState(value === undefined ? '' : String(value));
204
+ const [shown, setShown] = useState(value);
205
+ if (value !== shown) {
206
+ setShown(value);
207
+ setText(value === undefined ? '' : String(value));
208
+ }
209
+ return (
210
+ <div className="flex flex-col gap-1">
211
+ <label htmlFor={id} className="text-small text-content">
212
+ {label}
213
+ </label>
214
+ <input
215
+ id={id}
216
+ type="number"
217
+ inputMode="numeric"
218
+ min={0}
219
+ max={100}
220
+ step={1}
221
+ value={text}
222
+ placeholder={String(placeholder)}
223
+ disabled={disabled}
224
+ onChange={(event) => {
225
+ setText(event.target.value);
226
+ const next = event.target.valueAsNumber;
227
+ if (Number.isFinite(next) && next >= 0 && next <= 100) {
228
+ setShown(Math.round(next));
229
+ onChange(Math.round(next));
230
+ }
231
+ }}
232
+ aria-describedby={`${id}-hint`}
233
+ className={cx(inputClass, 'w-28')}
234
+ />
235
+ <p id={`${id}-hint`} className="text-small text-content-muted">
236
+ {hint}
237
+ </p>
238
+ </div>
239
+ );
240
+ }