@commonpub/layer 0.79.0 → 0.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,342 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Reusable image cropper modal. Shows a fixed aspect-ratio crop frame; the user
4
+ * drags to reposition and zooms (slider / +/- / scroll / pinch) the image behind
5
+ * it, so the crop is WYSIWYG for the target element (avatar 1:1, banner 4:1,
6
+ * cover 16:9). Emits a cropped Blob. Built on vue-advanced-cropper for correct
7
+ * EXIF/touch/retina handling; styled entirely to the design system.
8
+ */
9
+ import { Cropper } from 'vue-advanced-cropper';
10
+ import 'vue-advanced-cropper/dist/style.css';
11
+
12
+ const props = withDefaults(defineProps<{
13
+ /** Source image as a data/object URL. */
14
+ src: string;
15
+ /** Crop aspect ratio (width / height), e.g. 1, 4, 16/9. */
16
+ aspectRatio: number;
17
+ /** Show a circular mask hint over the (still square) crop — for avatars.
18
+ * Avatars also export as PNG to preserve transparency (e.g. logos). */
19
+ round?: boolean;
20
+ /** Max output width in px; height derives from the aspect ratio. */
21
+ outputWidth?: number;
22
+ title?: string;
23
+ }>(), {
24
+ round: false,
25
+ outputWidth: 1600,
26
+ title: 'Crop image',
27
+ });
28
+
29
+ const emit = defineEmits<{ crop: [blob: Blob]; cancel: [] }>();
30
+
31
+ // vue-advanced-cropper's instance methods (getResult/zoom) aren't in its exported
32
+ // types; reference them through a loose handle.
33
+ interface CropperHandle {
34
+ getResult: () => { coordinates?: { width: number; height: number }; canvas?: HTMLCanvasElement };
35
+ zoom: (factor: number) => void;
36
+ }
37
+ const cropperRef = ref<CropperHandle | null>(null);
38
+
39
+ // Modal a11y: focus trap, scroll lock, Esc, focus restore (shared pattern).
40
+ const dialogRef = ref<HTMLElement | null>(null);
41
+ const visible = ref(false);
42
+ onMounted(() => { visible.value = true; });
43
+ useFocusTrap(dialogRef, () => visible.value, () => emit('cancel'));
44
+
45
+ const ZOOM_MAX = 5;
46
+ const zoom = ref(1);
47
+ /** Crop width (in image px) at min zoom = "fit". image-restriction keeps the
48
+ * image covering the stencil, so this is the largest crop width ever seen. */
49
+ let fitCropWidth = 0;
50
+ const saving = ref(false);
51
+
52
+ // Reset zoom bookkeeping when the source image changes (defensive — today the
53
+ // parent remounts the modal per file, but don't rely on that).
54
+ watch(() => props.src, () => { fitCropWidth = 0; zoom.value = 1; });
55
+
56
+ const stencilProps = computed(() => ({
57
+ aspectRatio: props.aspectRatio,
58
+ movable: false,
59
+ resizable: false,
60
+ // The frame is fixed; the user manipulates the image, not the stencil. Don't
61
+ // render resize handles/lines (they'd be dead, non-draggable decorations).
62
+ handlers: {},
63
+ lines: {},
64
+ }));
65
+
66
+ function stencilSize({ boundaries }: { boundaries: { width: number; height: number } }): { width: number; height: number } {
67
+ let w = boundaries.width;
68
+ let h = w / props.aspectRatio;
69
+ if (h > boundaries.height) {
70
+ h = boundaries.height;
71
+ w = h * props.aspectRatio;
72
+ }
73
+ return { width: Math.round(w * 0.92), height: Math.round(h * 0.92) };
74
+ }
75
+
76
+ const exportMime = computed(() => (props.round ? 'image/png' : 'image/jpeg'));
77
+ const canvasOpts = computed(() => ({
78
+ maxWidth: props.outputWidth,
79
+ maxHeight: Math.round(props.outputWidth / props.aspectRatio),
80
+ // Only flatten onto white for JPEG (no alpha). PNG keeps transparency.
81
+ ...(props.round ? {} : { fillColor: '#ffffff' }),
82
+ }));
83
+
84
+ function onChange(result: { coordinates?: { width: number } }): void {
85
+ const w = result?.coordinates?.width;
86
+ if (!w) return;
87
+ if (w > fitCropWidth) fitCropWidth = w;
88
+ if (fitCropWidth > 0) zoom.value = Math.min(ZOOM_MAX, Math.max(1, fitCropWidth / w));
89
+ }
90
+
91
+ function applyZoom(target: number): void {
92
+ const c = cropperRef.value;
93
+ if (!c || !fitCropWidth) return;
94
+ const curW = c.getResult().coordinates?.width;
95
+ if (!curW) return;
96
+ const t = Math.min(ZOOM_MAX, Math.max(1, target));
97
+ const factor = curW / (fitCropWidth / t);
98
+ if (Number.isFinite(factor) && factor > 0) c.zoom(factor);
99
+ }
100
+
101
+ function onSlider(e: Event): void { applyZoom(Number((e.target as HTMLInputElement).value)); }
102
+ function zoomIn(): void { applyZoom(zoom.value * 1.25); }
103
+ function zoomOut(): void { applyZoom(zoom.value / 1.25); }
104
+
105
+ function apply(): void {
106
+ const c = cropperRef.value;
107
+ if (!c) return;
108
+ const { canvas } = c.getResult();
109
+ if (!canvas) { emit('cancel'); return; }
110
+ saving.value = true;
111
+ canvas.toBlob((blob) => {
112
+ saving.value = false;
113
+ if (blob) emit('crop', blob); else emit('cancel');
114
+ }, exportMime.value, 0.92);
115
+ }
116
+ </script>
117
+
118
+ <template>
119
+ <Teleport to="body">
120
+ <div class="cpub-crop-overlay" @click.self="emit('cancel')">
121
+ <div ref="dialogRef" class="cpub-crop-modal" role="dialog" aria-modal="true" :aria-label="title">
122
+ <header class="cpub-crop-head">
123
+ <span class="cpub-crop-title">{{ title }}</span>
124
+ <button type="button" class="cpub-crop-x" aria-label="Cancel" @click="emit('cancel')">
125
+ <i class="fa-solid fa-xmark"></i>
126
+ </button>
127
+ </header>
128
+
129
+ <div class="cpub-crop-stage" :class="{ 'cpub-crop-stage-round': round }">
130
+ <Cropper
131
+ ref="cropperRef"
132
+ class="cpub-cropper"
133
+ :src="src"
134
+ :stencil-props="stencilProps"
135
+ :stencil-size="stencilSize"
136
+ image-restriction="stencil"
137
+ :resize-image="{ touch: true, wheel: { ratio: 0.1 }, adjustStencil: false }"
138
+ :move-image="{ touch: true, mouse: true }"
139
+ :canvas="canvasOpts"
140
+ :transitions="true"
141
+ @change="onChange"
142
+ />
143
+ <div v-if="round" class="cpub-crop-round-mask" aria-hidden="true"></div>
144
+ </div>
145
+
146
+ <div class="cpub-crop-controls">
147
+ <button type="button" class="cpub-crop-zbtn" aria-label="Zoom out" @click="zoomOut">
148
+ <i class="fa-solid fa-minus"></i>
149
+ </button>
150
+ <input
151
+ class="cpub-crop-slider"
152
+ type="range"
153
+ :min="1"
154
+ :max="ZOOM_MAX"
155
+ step="0.01"
156
+ :value="zoom"
157
+ aria-label="Zoom"
158
+ @input="onSlider"
159
+ />
160
+ <button type="button" class="cpub-crop-zbtn" aria-label="Zoom in" @click="zoomIn">
161
+ <i class="fa-solid fa-plus"></i>
162
+ </button>
163
+ </div>
164
+
165
+ <p class="cpub-crop-hint">Drag to reposition. Scroll, pinch, or use the slider to zoom.</p>
166
+
167
+ <footer class="cpub-crop-foot">
168
+ <button type="button" class="cpub-btn" @click="emit('cancel')">Cancel</button>
169
+ <button type="button" class="cpub-btn cpub-btn-primary" :disabled="saving" @click="apply">
170
+ {{ saving ? 'Saving...' : 'Apply' }}
171
+ </button>
172
+ </footer>
173
+ </div>
174
+ </div>
175
+ </Teleport>
176
+ </template>
177
+
178
+ <style scoped>
179
+ .cpub-crop-overlay {
180
+ position: fixed;
181
+ inset: 0;
182
+ z-index: var(--z-modal, 9999);
183
+ background: var(--color-surface-scrim, rgba(0, 0, 0, 0.6));
184
+ display: flex;
185
+ align-items: center;
186
+ justify-content: center;
187
+ padding: 20px;
188
+ }
189
+
190
+ .cpub-crop-modal {
191
+ width: 100%;
192
+ max-width: 560px;
193
+ background: var(--surface);
194
+ border: var(--border-width-default, 2px) solid var(--border);
195
+ box-shadow: var(--shadow-lg, 6px 6px 0 var(--border));
196
+ display: flex;
197
+ flex-direction: column;
198
+ }
199
+
200
+ .cpub-crop-head {
201
+ display: flex;
202
+ align-items: center;
203
+ justify-content: space-between;
204
+ padding: 12px 16px;
205
+ border-bottom: var(--border-width-default, 2px) solid var(--border);
206
+ }
207
+
208
+ .cpub-crop-title {
209
+ font-family: var(--font-mono);
210
+ font-size: 0.75rem;
211
+ font-weight: 600;
212
+ text-transform: uppercase;
213
+ letter-spacing: 0.06em;
214
+ color: var(--text);
215
+ }
216
+
217
+ .cpub-crop-x {
218
+ background: none;
219
+ border: none;
220
+ color: var(--text-dim);
221
+ cursor: pointer;
222
+ font-size: 1rem;
223
+ padding: 4px;
224
+ line-height: 1;
225
+ }
226
+ .cpub-crop-x:hover { color: var(--text); }
227
+ .cpub-crop-x:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
228
+
229
+ .cpub-crop-stage {
230
+ position: relative;
231
+ background: var(--surface2, var(--color-surface));
232
+ height: 340px;
233
+ overflow: hidden;
234
+ }
235
+
236
+ .cpub-cropper {
237
+ height: 100%;
238
+ width: 100%;
239
+ background: transparent;
240
+ }
241
+
242
+ /* Circular hint for avatars (the stored crop stays square). */
243
+ .cpub-crop-round-mask {
244
+ position: absolute;
245
+ inset: 0;
246
+ pointer-events: none;
247
+ display: flex;
248
+ align-items: center;
249
+ justify-content: center;
250
+ }
251
+ .cpub-crop-round-mask::after {
252
+ content: '';
253
+ height: 92%;
254
+ aspect-ratio: 1;
255
+ border-radius: 50%;
256
+ border: var(--border-width-default, 2px) dashed var(--accent);
257
+ }
258
+
259
+ /* Style vue-advanced-cropper internals to the design system. */
260
+ .cpub-cropper :deep(.vue-advanced-cropper__background),
261
+ .cpub-cropper :deep(.vue-advanced-cropper__foreground) {
262
+ background: var(--color-surface-scrim, rgba(0, 0, 0, 0.55));
263
+ }
264
+ /* Belt-and-suspenders with handlers:{}/lines:{}: never show the resize chrome. */
265
+ .cpub-cropper :deep(.vue-line-wrapper),
266
+ .cpub-cropper :deep(.vue-handler-wrapper) {
267
+ display: none;
268
+ }
269
+ /* One clean accent frame around the crop window (outline = no layout shift,
270
+ no per-side border doubling). Verified via headless render: the crop window is
271
+ `.vue-rectangle-stencil`. */
272
+ .cpub-cropper :deep(.vue-rectangle-stencil) {
273
+ outline: var(--border-width-default, 2px) solid var(--accent);
274
+ outline-offset: calc(-1 * var(--border-width-default, 2px));
275
+ }
276
+
277
+ .cpub-crop-controls {
278
+ display: flex;
279
+ align-items: center;
280
+ gap: 12px;
281
+ padding: 14px 16px 4px;
282
+ }
283
+
284
+ .cpub-crop-zbtn {
285
+ flex: 0 0 auto;
286
+ width: 30px;
287
+ height: 30px;
288
+ display: inline-flex;
289
+ align-items: center;
290
+ justify-content: center;
291
+ background: var(--surface);
292
+ border: var(--border-width-default, 2px) solid var(--border);
293
+ color: var(--text-dim);
294
+ cursor: pointer;
295
+ }
296
+ .cpub-crop-zbtn:hover { border-color: var(--accent); color: var(--accent); }
297
+ .cpub-crop-zbtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
298
+
299
+ .cpub-crop-slider {
300
+ flex: 1;
301
+ appearance: none;
302
+ height: 4px;
303
+ background: var(--border);
304
+ cursor: pointer;
305
+ }
306
+ .cpub-crop-slider:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
307
+ .cpub-crop-slider::-webkit-slider-thumb {
308
+ appearance: none;
309
+ width: 16px;
310
+ height: 16px;
311
+ border-radius: 0;
312
+ background: var(--accent);
313
+ border: var(--border-width-default, 2px) solid var(--accent);
314
+ cursor: pointer;
315
+ }
316
+ .cpub-crop-slider::-moz-range-thumb {
317
+ width: 14px;
318
+ height: 14px;
319
+ background: var(--accent);
320
+ border: var(--border-width-default, 2px) solid var(--accent);
321
+ border-radius: 0;
322
+ cursor: pointer;
323
+ }
324
+
325
+ .cpub-crop-hint {
326
+ font-size: 0.6875rem;
327
+ color: var(--text-faint);
328
+ text-align: center;
329
+ padding: 4px 16px 0;
330
+ }
331
+
332
+ .cpub-crop-foot {
333
+ display: flex;
334
+ justify-content: flex-end;
335
+ gap: 10px;
336
+ padding: 16px;
337
+ }
338
+
339
+ @media (max-width: 560px) {
340
+ .cpub-crop-stage { height: 280px; }
341
+ }
342
+ </style>
@@ -5,6 +5,8 @@ const props = defineProps<{
5
5
  label?: string;
6
6
  hint?: string;
7
7
  aspectClass?: string;
8
+ /** Override the crop aspect ratio (width/height). Defaults by purpose. */
9
+ aspectRatio?: number;
8
10
  }>();
9
11
 
10
12
  const emit = defineEmits<{
@@ -15,31 +17,50 @@ const uploading = ref(false);
15
17
  const error = ref('');
16
18
  const fileInput = ref<HTMLInputElement | null>(null);
17
19
 
20
+ // The image is cropped to the target element's aspect ratio before upload, so it
21
+ // displays WYSIWYG instead of being arbitrarily cover-cropped on the page.
22
+ const cropConfig = computed(() => {
23
+ switch (props.purpose) {
24
+ case 'avatar': return { ratio: props.aspectRatio ?? 1, round: true, output: 512, title: 'Crop avatar' };
25
+ case 'banner': return { ratio: props.aspectRatio ?? 4, round: false, output: 1600, title: 'Crop banner' };
26
+ case 'cover': return { ratio: props.aspectRatio ?? 16 / 9, round: false, output: 1600, title: 'Crop cover image' };
27
+ default: return { ratio: props.aspectRatio ?? 1, round: false, output: 1200, title: 'Crop image' };
28
+ }
29
+ });
30
+
31
+ const cropSrc = ref('');
32
+
18
33
  function triggerPicker(): void {
19
34
  fileInput.value?.click();
20
35
  }
21
36
 
22
- async function handleFileChange(event: Event): Promise<void> {
37
+ function handleFileChange(event: Event): void {
23
38
  const target = event.target as HTMLInputElement;
24
39
  const file = target.files?.[0];
25
40
  if (!file) return;
26
41
 
27
42
  if (!file.type.startsWith('image/')) {
28
43
  error.value = 'Please select an image file.';
29
- return;
30
- }
31
-
32
- if (file.size > 10 * 1024 * 1024) {
44
+ } else if (file.size > 10 * 1024 * 1024) {
33
45
  error.value = 'Image must be under 10MB.';
34
- return;
46
+ } else {
47
+ error.value = '';
48
+ const reader = new FileReader();
49
+ reader.onload = () => { cropSrc.value = reader.result as string; };
50
+ reader.onerror = () => { error.value = 'Could not read the image.'; };
51
+ reader.readAsDataURL(file);
35
52
  }
53
+ target.value = '';
54
+ }
36
55
 
37
- error.value = '';
56
+ async function onCropped(blob: Blob): Promise<void> {
57
+ cropSrc.value = '';
38
58
  uploading.value = true;
39
-
59
+ error.value = '';
40
60
  try {
61
+ const ext = blob.type === 'image/png' ? 'png' : 'jpg';
41
62
  const formData = new FormData();
42
- formData.append('file', file);
63
+ formData.append('file', new File([blob], `${props.purpose}.${ext}`, { type: blob.type || 'image/jpeg' }));
43
64
  formData.append('purpose', props.purpose);
44
65
 
45
66
  const result = await $fetch<{ url: string }>('/api/files/upload', {
@@ -52,7 +73,6 @@ async function handleFileChange(event: Event): Promise<void> {
52
73
  error.value = (err as { data?: { statusMessage?: string } })?.data?.statusMessage || 'Upload failed.';
53
74
  } finally {
54
75
  uploading.value = false;
55
- if (target) target.value = '';
56
76
  }
57
77
  }
58
78
 
@@ -94,6 +114,19 @@ function clearImage(): void {
94
114
 
95
115
  <span v-if="hint && !error" class="cpub-img-hint">{{ hint }}</span>
96
116
  <span v-if="error" class="cpub-img-error">{{ error }}</span>
117
+
118
+ <ClientOnly>
119
+ <ImageCropperModal
120
+ v-if="cropSrc"
121
+ :src="cropSrc"
122
+ :aspect-ratio="cropConfig.ratio"
123
+ :round="cropConfig.round"
124
+ :output-width="cropConfig.output"
125
+ :title="cropConfig.title"
126
+ @crop="onCropped"
127
+ @cancel="cropSrc = ''"
128
+ />
129
+ </ClientOnly>
97
130
  </div>
98
131
  </template>
99
132
 
@@ -86,5 +86,14 @@ export function useFocusTrap(
86
86
  onUnmounted(() => {
87
87
  document.body.style.overflow = '';
88
88
  document.removeEventListener('keydown', handleKeydown);
89
+ // Most consumers close by v-if-unmounting while still "open" (the isOpen
90
+ // getter never flips false), so the watch's else-branch never restores
91
+ // focus. Restore here too. `previousActive` is nulled by the else-branch
92
+ // when it does run, so this won't double-restore. Focusing a detached
93
+ // element is a harmless no-op.
94
+ if (previousActive) {
95
+ previousActive.focus();
96
+ previousActive = null;
97
+ }
89
98
  });
90
99
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.79.0",
3
+ "version": "0.80.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -51,19 +51,20 @@
51
51
  "sharp": "^0.34.5",
52
52
  "shiki": "^4.0.2",
53
53
  "vue": "^3.4.0",
54
+ "vue-advanced-cropper": "^2.8.9",
54
55
  "vue-router": "^4.3.0",
55
56
  "zod": "^4.3.6",
56
57
  "@commonpub/auth": "0.8.0",
57
58
  "@commonpub/docs": "0.6.3",
58
- "@commonpub/explainer": "0.7.15",
59
+ "@commonpub/config": "0.22.1",
60
+ "@commonpub/protocol": "0.13.0",
59
61
  "@commonpub/learning": "0.5.2",
60
- "@commonpub/editor": "0.7.12",
62
+ "@commonpub/theme-studio": "0.6.1",
61
63
  "@commonpub/schema": "0.44.0",
62
64
  "@commonpub/server": "2.88.0",
63
- "@commonpub/protocol": "0.13.0",
64
65
  "@commonpub/ui": "0.13.1",
65
- "@commonpub/theme-studio": "0.6.1",
66
- "@commonpub/config": "0.22.1"
66
+ "@commonpub/explainer": "0.7.15",
67
+ "@commonpub/editor": "0.7.12"
67
68
  },
68
69
  "devDependencies": {
69
70
  "@testing-library/jest-dom": "^6.9.1",