@elixpo/lixsketch 5.4.6 → 5.4.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elixpo/lixsketch",
3
- "version": "5.4.6",
3
+ "version": "5.4.7",
4
4
  "description": "Open-source SVG whiteboard engine with hand-drawn aesthetics — the core of LixSketch",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -13,6 +13,8 @@ import { Frame } from '../shapes/Frame.js';
13
13
  import { TextShape } from '../shapes/TextShape.js';
14
14
  import { CodeShape } from '../shapes/CodeShape.js';
15
15
  import { ImageShape } from '../shapes/ImageShape.js';
16
+ import { isAllowedImage, isAllowedImageDataUrl } from '../utils/allowedImageTypes.js';
17
+ import { compressImage } from '../utils/imageCompressor.js';
16
18
  import { IconShape } from '../shapes/IconShape.js';
17
19
 
18
20
  // Clipboard storage
@@ -585,10 +587,27 @@ function handlePasteEvent(e) {
585
587
  const blob = item.getAsFile();
586
588
  if (!blob) return;
587
589
 
590
+ // Validate against the canonical allowlist — clipboard sources
591
+ // can include animated GIFs / HEIC / TIFF / arbitrary mime types.
592
+ if (!isAllowedImage(blob)) {
593
+ console.warn('[CopyPaste] Rejected pasted image type:', blob.type);
594
+ return;
595
+ }
596
+
588
597
  const reader = new FileReader();
589
- reader.onload = (ev) => {
590
- const dataUrl = ev.target.result;
591
- placeImageFromDataUrl(dataUrl);
598
+ reader.onload = async (ev) => {
599
+ const rawDataUrl = ev.target.result;
600
+ const isSvg = (blob.type || '').toLowerCase() === 'image/svg+xml';
601
+ let placedDataUrl = rawDataUrl;
602
+ if (!isSvg) {
603
+ try {
604
+ const compressed = await compressImage(rawDataUrl);
605
+ if (compressed?.dataUrl) placedDataUrl = compressed.dataUrl;
606
+ } catch (err) {
607
+ console.warn('[CopyPaste] Pre-placement compression failed, using raw:', err);
608
+ }
609
+ }
610
+ placeImageFromDataUrl(placedDataUrl);
592
611
  };
593
612
  reader.readAsDataURL(blob);
594
613
  return; // only handle first image
package/src/index.js CHANGED
@@ -54,6 +54,17 @@ export { saveScene, loadScene } from './core/SceneSerializer.js';
54
54
  // shipping them across a postMessage boundary.
55
55
  export { compressImage } from './utils/imageCompressor.js';
56
56
 
57
+ // Canonical image allowlist. The engine and any embedding host should
58
+ // reject anything outside this set at every entry point (file picker,
59
+ // drag-drop, paste, server boundary).
60
+ export {
61
+ ALLOWED_IMAGE_MIME_TYPES,
62
+ ALLOWED_IMAGE_EXTENSIONS,
63
+ IMAGE_ACCEPT_ATTR,
64
+ isAllowedImage,
65
+ isAllowedImageDataUrl,
66
+ } from './utils/allowedImageTypes.js';
67
+
57
68
  // Engine-local keyboard shortcuts (tool switching, delete, group/ungroup,
58
69
  // space-to-pan, escape-deselect). App-level shortcuts (cloud save, modal
59
70
  // toggles) are intentionally NOT included — those belong to the consumer.
@@ -3,6 +3,7 @@
3
3
  import { pushCreateAction, pushDeleteAction, pushTransformAction, pushFrameAttachmentAction } from '../core/UndoRedo.js';
4
4
  import { updateAttachedArrows as updateArrowsForShape, cleanupAttachments } from './arrowTool.js';
5
5
  import { compressImage } from '../utils/imageCompressor.js';
6
+ import { isAllowedImage, IMAGE_ACCEPT_ATTR } from '../utils/allowedImageTypes.js';
6
7
 
7
8
 
8
9
  let isDraggingImage = false;
@@ -133,7 +134,7 @@ document.getElementById("importImage")?.addEventListener('click', () => {
133
134
  // Create a file input element
134
135
  const fileInput = document.createElement('input');
135
136
  fileInput.type = 'file';
136
- fileInput.accept = 'image/*'; // Accept all image types
137
+ fileInput.accept = IMAGE_ACCEPT_ATTR; // Static images only — see allowedImageTypes.js
137
138
  fileInput.style.display = 'none'; // Hide the input element
138
139
 
139
140
  // Add the input to the document temporarily
@@ -194,13 +195,16 @@ const loadHardcodedImage = (imagePath) => {
194
195
  img.src = imagePath;
195
196
  };
196
197
 
197
- const handleImageUpload = (file) => {
198
+ const handleImageUpload = async (file) => {
198
199
  if (!file || !isImageToolActive) return;
199
200
 
200
- // Validate file type
201
- if (!file.type.startsWith('image/')) {
202
- console.error('Selected file is not an image');
203
- alert('Please select a valid image file.');
201
+ // Validate file type against the canonical allowlist (avif, jpeg, jpg,
202
+ // png, bmp, svg, webp). Animated GIF, HEIC, TIFF, video, audio, and
203
+ // arbitrary files are rejected here.
204
+ if (!isAllowedImage(file)) {
205
+ console.error('Rejected file type:', file.type, file.name);
206
+ alert('Unsupported file type. Allowed: AVIF, JPEG, PNG, BMP, SVG, WebP.');
207
+ isImageToolActive = false;
204
208
  return;
205
209
  }
206
210
 
@@ -222,26 +226,50 @@ const handleImageUpload = (file) => {
222
226
 
223
227
  console.log('Processing image file:', file.name, 'Size:', file.size, 'Type:', file.type);
224
228
 
225
- const reader = new FileReader();
226
-
227
- // Store file size for room limit tracking
228
- window.__pendingImageFileSize = file.size;
229
+ // Read optionally compress → place. We compress client-side BEFORE
230
+ // placement so that consumers without an upload pipeline (VS Code
231
+ // extension, offline npm usage) still embed a compressed data URL
232
+ // rather than the original. SVGs are passed through unchanged because
233
+ // rasterizing them would destroy their vector fidelity.
234
+ try {
235
+ const rawDataUrl = await readFileAsDataUrl(file);
236
+ const isSvg = (file.type || '').toLowerCase() === 'image/svg+xml'
237
+ || (file.name || '').toLowerCase().endsWith('.svg');
238
+
239
+ let placedDataUrl = rawDataUrl;
240
+ let placedSize = file.size;
241
+ if (!isSvg) {
242
+ try {
243
+ const compressed = await compressImage(rawDataUrl);
244
+ if (compressed?.dataUrl) {
245
+ placedDataUrl = compressed.dataUrl;
246
+ placedSize = compressed.compressedSize || placedSize;
247
+ }
248
+ } catch (err) {
249
+ console.warn('[ImageTool] Pre-placement compression failed, using raw:', err);
250
+ }
251
+ }
229
252
 
230
- reader.onload = (e) => {
231
- imageToPlace = e.target.result;
253
+ window.__pendingImageFileSize = placedSize;
254
+ imageToPlace = placedDataUrl;
232
255
  isDraggingImage = true;
233
- console.log('Image loaded and ready to place');
234
- };
235
-
236
- reader.onerror = (error) => {
237
- console.error('Error reading file:', error);
256
+ console.log('Image loaded and ready to place', { size: placedSize, isSvg });
257
+ } catch (err) {
258
+ console.error('Error reading file:', err);
238
259
  alert('Error reading the image file. Please try again.');
239
260
  isImageToolActive = false;
240
- };
241
-
242
- reader.readAsDataURL(file);
261
+ }
243
262
  };
244
263
 
264
+ function readFileAsDataUrl(file) {
265
+ return new Promise((resolve, reject) => {
266
+ const reader = new FileReader();
267
+ reader.onload = (e) => resolve(e.target.result);
268
+ reader.onerror = (err) => reject(err);
269
+ reader.readAsDataURL(file);
270
+ });
271
+ }
272
+
245
273
  // Add coordinate conversion function like in other tools
246
274
  function getSVGCoordsFromMouse(e) {
247
275
  const viewBox = svg.viewBox.baseVal;
@@ -1286,7 +1314,7 @@ window.openImageFilePicker = function() {
1286
1314
  isImageToolActive = true;
1287
1315
  const fileInput = document.createElement('input');
1288
1316
  fileInput.type = 'file';
1289
- fileInput.accept = 'image/*';
1317
+ fileInput.accept = IMAGE_ACCEPT_ATTR;
1290
1318
  fileInput.style.display = 'none';
1291
1319
  document.body.appendChild(fileInput);
1292
1320
 
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Canonical allowlist for image uploads across LixSketch and any embedding
3
+ * host (e.g. blogs.elixpo). Anything not in this list is rejected at file
4
+ * pick / paste / drop time and again at the server boundary.
5
+ *
6
+ * Static raster + vector formats only. Animated GIF is intentionally excluded
7
+ * (animation isn't supported by the SVG renderer or the blog reader); HEIC,
8
+ * TIFF, video, audio, PDF, and arbitrary files are excluded by omission.
9
+ */
10
+
11
+ export const ALLOWED_IMAGE_MIME_TYPES = Object.freeze([
12
+ 'image/avif',
13
+ 'image/jpeg',
14
+ 'image/png',
15
+ 'image/bmp',
16
+ 'image/svg+xml',
17
+ 'image/webp',
18
+ ]);
19
+
20
+ export const ALLOWED_IMAGE_EXTENSIONS = Object.freeze([
21
+ '.avif',
22
+ '.jpeg',
23
+ '.jpg',
24
+ '.png',
25
+ '.bmp',
26
+ '.svg',
27
+ '.webp',
28
+ ]);
29
+
30
+ /** Comma-joined value suitable for an <input accept="…"> attribute. */
31
+ export const IMAGE_ACCEPT_ATTR = ALLOWED_IMAGE_MIME_TYPES.join(',');
32
+
33
+ /**
34
+ * Validate a File / Blob against the allowlist by mime type, falling back
35
+ * to extension matching when the browser doesn't fill in `type` (some
36
+ * drag-drop sources, some SVGs).
37
+ */
38
+ export function isAllowedImage(file) {
39
+ if (!file) return false;
40
+ const type = (file.type || '').toLowerCase();
41
+ if (type && ALLOWED_IMAGE_MIME_TYPES.includes(type)) return true;
42
+ const name = (file.name || '').toLowerCase();
43
+ if (!name) return false;
44
+ return ALLOWED_IMAGE_EXTENSIONS.some((ext) => name.endsWith(ext));
45
+ }
46
+
47
+ /** Same check but for a base64 data URL (e.g. from clipboard paste). */
48
+ export function isAllowedImageDataUrl(dataUrl) {
49
+ if (typeof dataUrl !== 'string') return false;
50
+ const m = dataUrl.match(/^data:([^;]+);/i);
51
+ if (!m) return false;
52
+ return ALLOWED_IMAGE_MIME_TYPES.includes(m[1].toLowerCase());
53
+ }