@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.
- package/README.md +119 -2
- package/dist/BodyEditor-_mkh0BSQ.js +3047 -0
- package/dist/a11y/FieldNotes.d.ts +23 -0
- package/dist/a11y/NotesDrawer.d.ts +23 -0
- package/dist/a11y/pdf.d.ts +3 -0
- package/dist/a11y/run.d.ts +3 -0
- package/dist/a11y/useNotes.d.ts +28 -0
- package/dist/app/App.d.ts +2 -0
- package/dist/app/Screen.d.ts +88 -0
- package/dist/app/UserMenu.d.ts +7 -0
- package/dist/app/api.d.ts +75 -0
- package/dist/app/appearance.d.ts +10 -0
- package/dist/app/browser-api.d.ts +17 -0
- package/dist/app/collection-icon.d.ts +12 -0
- package/dist/app/context.d.ts +23 -0
- package/dist/app/entries-changed.d.ts +11 -0
- package/dist/app/file-url.d.ts +13 -0
- package/dist/app/format.d.ts +4 -0
- package/dist/app/idb.d.ts +7 -0
- package/dist/app/load.d.ts +16 -0
- package/dist/app/media.d.ts +5 -0
- package/dist/app/navigation.d.ts +4 -0
- package/dist/app/pane-context.d.ts +19 -0
- package/dist/app/panes.d.ts +14 -0
- package/dist/app/router.d.ts +62 -0
- package/dist/body/BodyEditor.d.ts +42 -0
- package/dist/body/TableView.d.ts +2 -0
- package/dist/body/catalog.d.ts +47 -0
- package/dist/body/checks.d.ts +15 -0
- package/dist/body/chips.d.ts +31 -0
- package/dist/body/commands.d.ts +31 -0
- package/dist/body/document.d.ts +42 -0
- package/dist/body/insert.d.ts +69 -0
- package/dist/body/nodes.d.ts +38 -0
- package/dist/body/reveal.d.ts +13 -0
- package/dist/body/scroll.d.ts +16 -0
- package/dist/body/settings.d.ts +19 -0
- package/dist/body/shortcuts.d.ts +13 -0
- package/dist/body/table.d.ts +31 -0
- package/dist/body/ui.d.ts +30 -0
- package/dist/body/views.d.ts +9 -0
- package/dist/checks-C-0xDu-I.js +57 -0
- package/dist/cloud/CloudGate.d.ts +14 -0
- package/dist/cloud/access.d.ts +14 -0
- package/dist/cloud/changes.d.ts +26 -0
- package/dist/cloud/connection.d.ts +100 -0
- package/dist/cloud/editor.d.ts +44 -0
- package/dist/cloud/session.d.ts +62 -0
- package/dist/cloud/storage.d.ts +45 -0
- package/dist/editor/Field.d.ts +24 -0
- package/dist/editor/PagePicker.d.ts +20 -0
- package/dist/editor/arrange.d.ts +13 -0
- package/dist/editor/context.d.ts +23 -0
- package/dist/editor/copy.d.ts +9 -0
- package/dist/editor/reveal.d.ts +7 -0
- package/dist/editor/target.d.ts +21 -0
- package/dist/editor/values.d.ts +18 -0
- package/dist/geo-map-CT3OGD7O.js +110 -0
- package/dist/github/GitHubGate.d.ts +15 -0
- package/dist/github/api.d.ts +20 -0
- package/dist/github/cache.d.ts +2 -0
- package/dist/github/session.d.ts +35 -0
- package/dist/index.d.ts +3 -0
- package/dist/markdown-4fVeej3c.js +211 -0
- package/dist/media/MediaPicker.d.ts +28 -0
- package/dist/media/file-icon.d.ts +3 -0
- package/dist/media/formats.d.ts +23 -0
- package/dist/media/hygiene.d.ts +37 -0
- package/dist/media/upload.d.ts +31 -0
- package/dist/mount.d.ts +3 -0
- package/dist/pdf-m5s_uXJ3.js +24 -0
- package/dist/run-y4XRkg17.js +7 -0
- package/dist/schedule/ScheduleDialog.d.ts +28 -0
- package/dist/schedule/ScheduleSummary.d.ts +12 -0
- package/dist/schedule/ScheduledView.d.ts +8 -0
- package/dist/schedule/cloud.d.ts +21 -0
- package/dist/schedule/now.d.ts +5 -0
- package/dist/schedule/times.d.ts +35 -0
- package/dist/screens/EntryEditor.d.ts +13 -0
- package/dist/screens/EntryList.d.ts +16 -0
- package/dist/screens/Home.d.ts +6 -0
- package/dist/screens/ListPane.d.ts +6 -0
- package/dist/screens/MediaBrowser.d.ts +7 -0
- package/dist/screens/NotFound.d.ts +3 -0
- package/dist/screens/PageTree.d.ts +10 -0
- package/dist/screens/Redirects.d.ts +1 -0
- package/dist/screens/Unavailable.d.ts +8 -0
- package/dist/screens/status.d.ts +36 -0
- package/dist/shortcuts-CarKeDqp.js +4666 -0
- package/dist/studio.css +3 -0
- package/dist/studio.js +6449 -0
- package/dist/types.d.ts +76 -0
- package/dist/widgets/basic.d.ts +25 -0
- package/dist/widgets/blank-date.d.ts +8 -0
- package/dist/widgets/crops.d.ts +43 -0
- package/dist/widgets/focal-point.d.ts +15 -0
- package/dist/widgets/geo-map.d.ts +10 -0
- package/dist/widgets/geo.d.ts +16 -0
- package/dist/widgets/group.d.ts +41 -0
- package/dist/widgets/icon.d.ts +9 -0
- package/dist/widgets/image.d.ts +16 -0
- package/dist/widgets/markdown-lazy.d.ts +2 -0
- package/dist/widgets/markdown.d.ts +2 -0
- package/dist/widgets/reference.d.ts +12 -0
- package/dist/widgets/registry.d.ts +32 -0
- package/dist/widgets/special.d.ts +20 -0
- package/dist/widgets/union.d.ts +36 -0
- package/package.json +63 -4
- package/src/a11y/FieldNotes.tsx +56 -0
- package/src/a11y/NotesDrawer.tsx +81 -0
- package/src/a11y/pdf.ts +45 -0
- package/src/a11y/run.ts +12 -0
- package/src/a11y/useNotes.ts +139 -0
- package/src/app/App.tsx +156 -0
- package/src/app/Screen.tsx +416 -0
- package/src/app/UserMenu.tsx +95 -0
- package/src/app/api.ts +176 -0
- package/src/app/appearance.ts +54 -0
- package/src/app/browser-api.ts +173 -0
- package/src/app/collection-icon.ts +15 -0
- package/src/app/context.tsx +32 -0
- package/src/app/entries-changed.ts +48 -0
- package/src/app/file-url.tsx +44 -0
- package/src/app/format.ts +39 -0
- package/src/app/idb.ts +82 -0
- package/src/app/load.ts +40 -0
- package/src/app/media.ts +20 -0
- package/src/app/navigation.ts +4 -0
- package/src/app/pane-context.ts +25 -0
- package/src/app/panes.tsx +513 -0
- package/src/app/router.ts +158 -0
- package/src/body/BodyEditor.tsx +1139 -0
- package/src/body/TableView.tsx +700 -0
- package/src/body/catalog.ts +198 -0
- package/src/body/checks.ts +83 -0
- package/src/body/chips.ts +150 -0
- package/src/body/commands.ts +191 -0
- package/src/body/document.ts +388 -0
- package/src/body/insert.tsx +301 -0
- package/src/body/nodes.ts +287 -0
- package/src/body/reveal.ts +93 -0
- package/src/body/scroll.ts +53 -0
- package/src/body/settings.tsx +510 -0
- package/src/body/shortcuts.ts +31 -0
- package/src/body/table.ts +192 -0
- package/src/body/ui.ts +77 -0
- package/src/body/views.tsx +268 -0
- package/src/build.ts +6 -0
- package/src/cloud/CloudGate.tsx +245 -0
- package/src/cloud/access.ts +52 -0
- package/src/cloud/changes.tsx +474 -0
- package/src/cloud/connection.ts +281 -0
- package/src/cloud/editor.tsx +250 -0
- package/src/cloud/session.ts +289 -0
- package/src/cloud/storage.ts +212 -0
- package/src/editor/Field.tsx +182 -0
- package/src/editor/PagePicker.tsx +102 -0
- package/src/editor/arrange.ts +98 -0
- package/src/editor/context.tsx +31 -0
- package/src/editor/copy.ts +30 -0
- package/src/editor/reveal.ts +38 -0
- package/src/editor/target.ts +104 -0
- package/src/editor/values.ts +71 -0
- package/src/github/GitHubGate.tsx +298 -0
- package/src/github/api.ts +81 -0
- package/src/github/cache.ts +64 -0
- package/src/github/session.ts +146 -0
- package/src/index.ts +10 -0
- package/src/media/MediaPicker.tsx +302 -0
- package/src/media/file-icon.ts +17 -0
- package/src/media/formats.ts +262 -0
- package/src/media/hygiene.ts +220 -0
- package/src/media/upload.ts +47 -0
- package/src/mount.tsx +20 -0
- package/src/schedule/ScheduleDialog.tsx +303 -0
- package/src/schedule/ScheduleSummary.tsx +59 -0
- package/src/schedule/ScheduledView.tsx +294 -0
- package/src/schedule/cloud.ts +66 -0
- package/src/schedule/now.ts +18 -0
- package/src/schedule/times.ts +122 -0
- package/src/screens/EntryEditor.tsx +1817 -0
- package/src/screens/EntryList.tsx +272 -0
- package/src/screens/Home.tsx +131 -0
- package/src/screens/ListPane.tsx +477 -0
- package/src/screens/MediaBrowser.tsx +253 -0
- package/src/screens/NotFound.tsx +19 -0
- package/src/screens/PageTree.tsx +738 -0
- package/src/screens/Redirects.tsx +522 -0
- package/src/screens/Unavailable.tsx +27 -0
- package/src/screens/status.tsx +104 -0
- package/src/studio.css +168 -0
- package/src/types.ts +82 -0
- package/src/widgets/basic.tsx +315 -0
- package/src/widgets/blank-date.ts +32 -0
- package/src/widgets/crops.tsx +399 -0
- package/src/widgets/focal-point.tsx +240 -0
- package/src/widgets/geo-map.tsx +160 -0
- package/src/widgets/geo.tsx +170 -0
- package/src/widgets/group.tsx +827 -0
- package/src/widgets/icon.tsx +47 -0
- package/src/widgets/image.tsx +407 -0
- package/src/widgets/markdown-lazy.tsx +22 -0
- package/src/widgets/markdown.tsx +267 -0
- package/src/widgets/reference.tsx +196 -0
- package/src/widgets/registry.ts +104 -0
- package/src/widgets/special.tsx +290 -0
- 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
|
+
}
|