@elixpo/lixsketch 5.4.5 → 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 +1 -1
- package/src/EngineShortcuts.js +254 -0
- package/src/core/CopyPaste.js +22 -3
- package/src/index.js +21 -0
- package/src/tools/imageTool.js +54 -23
- package/src/utils/allowedImageTypes.js +53 -0
package/package.json
CHANGED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
/**
|
|
3
|
+
* Engine-local keyboard shortcuts.
|
|
4
|
+
*
|
|
5
|
+
* These are the shortcuts that operate purely on engine state (tool switching,
|
|
6
|
+
* shape deletion, selection, grouping, space-to-pan, escape-deselect). They
|
|
7
|
+
* intentionally do NOT include product-level shortcuts (cloud save, modal
|
|
8
|
+
* toggles, theme switches) — those belong to the consumer app.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* import { installEngineShortcuts } from '@elixpo/lixsketch';
|
|
12
|
+
* const uninstall = installEngineShortcuts(engine);
|
|
13
|
+
* // later:
|
|
14
|
+
* uninstall();
|
|
15
|
+
*
|
|
16
|
+
* Hosts can pass `options.onToast(message, tone)` to surface group/ungroup
|
|
17
|
+
* confirmations, and `options.skipWhen(event)` to suppress shortcuts in
|
|
18
|
+
* specific contexts (e.g. when the host's overlay is focused).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { TOOLS } from './index.js';
|
|
22
|
+
|
|
23
|
+
// Single-key tool shortcuts. Mirrors what the standalone product ships.
|
|
24
|
+
export const SHORTCUT_MAP = {
|
|
25
|
+
h: TOOLS.PAN,
|
|
26
|
+
v: TOOLS.SELECT,
|
|
27
|
+
1: TOOLS.SELECT,
|
|
28
|
+
r: TOOLS.RECTANGLE,
|
|
29
|
+
2: TOOLS.RECTANGLE,
|
|
30
|
+
o: TOOLS.CIRCLE,
|
|
31
|
+
4: TOOLS.CIRCLE,
|
|
32
|
+
a: TOOLS.ARROW,
|
|
33
|
+
5: TOOLS.ARROW,
|
|
34
|
+
l: TOOLS.LINE,
|
|
35
|
+
6: TOOLS.LINE,
|
|
36
|
+
p: TOOLS.FREEHAND,
|
|
37
|
+
7: TOOLS.FREEHAND,
|
|
38
|
+
t: TOOLS.TEXT,
|
|
39
|
+
8: TOOLS.TEXT,
|
|
40
|
+
9: TOOLS.IMAGE,
|
|
41
|
+
e: TOOLS.ERASER,
|
|
42
|
+
0: TOOLS.ERASER,
|
|
43
|
+
i: TOOLS.ICON,
|
|
44
|
+
f: TOOLS.FRAME,
|
|
45
|
+
k: TOOLS.LASER,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function isTypingTarget(target) {
|
|
49
|
+
if (!target) return false;
|
|
50
|
+
const tag = (target.tagName || '').toLowerCase();
|
|
51
|
+
if (tag === 'input' || tag === 'textarea') return true;
|
|
52
|
+
if (target.isContentEditable) return true;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Install engine-local keyboard shortcuts on `document`.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} engine - The SketchEngine instance (must have `setActiveTool`).
|
|
60
|
+
* @param {object} [options]
|
|
61
|
+
* @param {function} [options.onToast] - (message, { tone }) => void; called
|
|
62
|
+
* for group/ungroup confirmations. No-op by default.
|
|
63
|
+
* @param {function} [options.skipWhen] - (event) => boolean; return true to
|
|
64
|
+
* skip shortcut handling for an event. Use to defer to your own
|
|
65
|
+
* editor overlays. Default skips inputs/contenteditable only.
|
|
66
|
+
* @param {function} [options.setActiveTool] - (tool) => void; override how
|
|
67
|
+
* tool switching is dispatched. Useful when the consumer has its
|
|
68
|
+
* own state store driving the engine — calling this keeps the UI
|
|
69
|
+
* in sync without polling. Defaults to engine.setActiveTool.
|
|
70
|
+
* @returns {function} uninstall function
|
|
71
|
+
*/
|
|
72
|
+
export function installEngineShortcuts(engine, options = {}) {
|
|
73
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
74
|
+
return () => { };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const onToast = typeof options.onToast === 'function' ? options.onToast : () => { };
|
|
78
|
+
const skipWhen = typeof options.skipWhen === 'function' ? options.skipWhen : null;
|
|
79
|
+
const customSetTool = typeof options.setActiveTool === 'function' ? options.setActiveTool : null;
|
|
80
|
+
|
|
81
|
+
function setTool(tool) {
|
|
82
|
+
if (customSetTool) { customSetTool(tool); return; }
|
|
83
|
+
if (engine?.setActiveTool) engine.setActiveTool(tool);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getActiveTool() {
|
|
87
|
+
return engine?.activeTool || engine?.getActiveTool?.() || null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function handleKeyDown(e) {
|
|
91
|
+
const key = (e.key || '').toLowerCase();
|
|
92
|
+
|
|
93
|
+
// Always check ctrl/cmd shortcuts first — they should fire even when
|
|
94
|
+
// selection is empty, but skip when the user is typing.
|
|
95
|
+
const isMod = e.ctrlKey || e.metaKey;
|
|
96
|
+
|
|
97
|
+
if (isTypingTarget(e.target)) return;
|
|
98
|
+
if (document.querySelector('.text-edit-overlay:not(.hidden)')) return;
|
|
99
|
+
if (skipWhen && skipWhen(e)) return;
|
|
100
|
+
|
|
101
|
+
if (isMod) {
|
|
102
|
+
if (key === 'a' && !e.shiftKey) {
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
setTool(TOOLS.SELECT);
|
|
105
|
+
if (window.multiSelection && Array.isArray(window.shapes)) {
|
|
106
|
+
window.multiSelection.clearSelection();
|
|
107
|
+
window.shapes.forEach((shape) => window.multiSelection.addShape(shape));
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (key === 'g' && !e.shiftKey) {
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
try {
|
|
114
|
+
const ms = window.multiSelection;
|
|
115
|
+
const sel = ms?.selectedShapes;
|
|
116
|
+
const targets = sel && sel.size > 1
|
|
117
|
+
? Array.from(sel)
|
|
118
|
+
: (window.currentShape ? [window.currentShape] : []);
|
|
119
|
+
if (targets.length > 1) {
|
|
120
|
+
const newId = `g-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
|
121
|
+
for (const s of targets) s.groupId = newId;
|
|
122
|
+
if (typeof ms?.updateControls === 'function') ms.updateControls();
|
|
123
|
+
onToast(`Grouped ${targets.length} shapes`, { tone: 'success' });
|
|
124
|
+
}
|
|
125
|
+
} catch (err) { console.warn('[EngineShortcuts] group failed:', err); }
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (key === 'g' && e.shiftKey) {
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
try {
|
|
131
|
+
const ms = window.multiSelection;
|
|
132
|
+
const sel = ms?.selectedShapes;
|
|
133
|
+
const targets = sel && sel.size > 0
|
|
134
|
+
? Array.from(sel)
|
|
135
|
+
: (window.currentShape ? [window.currentShape] : []);
|
|
136
|
+
const groupIds = new Set(targets.map((s) => s.groupId).filter(Boolean));
|
|
137
|
+
if (groupIds.size > 0 && Array.isArray(window.shapes)) {
|
|
138
|
+
let cleared = 0;
|
|
139
|
+
for (const s of window.shapes) {
|
|
140
|
+
if (s.groupId && groupIds.has(s.groupId)) { s.groupId = null; cleared++; }
|
|
141
|
+
}
|
|
142
|
+
if (typeof ms?.updateControls === 'function') ms.updateControls();
|
|
143
|
+
onToast(`Ungrouped ${cleared} shapes`, { tone: 'success' });
|
|
144
|
+
}
|
|
145
|
+
} catch (err) { console.warn('[EngineShortcuts] ungroup failed:', err); }
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// Ctrl+D = duplicate (engine-handled elsewhere — just preventDefault to
|
|
149
|
+
// stop the browser's bookmark dialog).
|
|
150
|
+
if (key === 'd') { e.preventDefault(); return; }
|
|
151
|
+
return; // Other Ctrl combos are app-level; let the consumer handle them.
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Delete / Backspace — remove selected shapes.
|
|
155
|
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
|
|
158
|
+
if (window.multiSelection?.selectedShapes?.size > 0) {
|
|
159
|
+
if (typeof window.deleteSelectedShapes === 'function') {
|
|
160
|
+
window.deleteSelectedShapes();
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (window.currentShape) {
|
|
166
|
+
const shape = window.currentShape;
|
|
167
|
+
const shapes = window.shapes;
|
|
168
|
+
if (shapes) {
|
|
169
|
+
const idx = shapes.indexOf(shape);
|
|
170
|
+
if (idx !== -1) shapes.splice(idx, 1);
|
|
171
|
+
}
|
|
172
|
+
if (typeof window.cleanupAttachments === 'function') {
|
|
173
|
+
window.cleanupAttachments(shape);
|
|
174
|
+
}
|
|
175
|
+
if (shape.parentFrame && typeof shape.parentFrame.removeShapeFromFrame === 'function') {
|
|
176
|
+
shape.parentFrame.removeShapeFromFrame(shape);
|
|
177
|
+
}
|
|
178
|
+
const el = shape.group || shape.element;
|
|
179
|
+
if (el && el.parentNode) el.parentNode.removeChild(el);
|
|
180
|
+
if (typeof window.pushDeleteAction === 'function') {
|
|
181
|
+
window.pushDeleteAction(shape);
|
|
182
|
+
}
|
|
183
|
+
window.currentShape = null;
|
|
184
|
+
if (typeof window.disableAllSideBars === 'function') {
|
|
185
|
+
window.disableAllSideBars();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Tool-switching shortcuts (no modifier).
|
|
192
|
+
if (!e.shiftKey && !e.altKey) {
|
|
193
|
+
const tool = SHORTCUT_MAP[key];
|
|
194
|
+
if (tool) {
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
setTool(tool);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (e.key === 'Escape') {
|
|
201
|
+
// Engine-level deselect; consumer handles modal-close ordering.
|
|
202
|
+
window.currentShape = null;
|
|
203
|
+
if (typeof window.disableAllSideBars === 'function') {
|
|
204
|
+
window.disableAllSideBars();
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Space held = temporary pan. Releasing restores the previous tool.
|
|
212
|
+
let spaceHeld = false;
|
|
213
|
+
let toolBeforeSpace = null;
|
|
214
|
+
|
|
215
|
+
function handleSpaceDown(e) {
|
|
216
|
+
if (e.code !== 'Space' || spaceHeld) return;
|
|
217
|
+
if (isTypingTarget(e.target)) return;
|
|
218
|
+
if (skipWhen && skipWhen(e)) return;
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
spaceHeld = true;
|
|
221
|
+
const active = getActiveTool();
|
|
222
|
+
if (active && active !== TOOLS.PAN) {
|
|
223
|
+
toolBeforeSpace = active;
|
|
224
|
+
setTool(TOOLS.PAN);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function handleKeyUp(e) {
|
|
229
|
+
if (e.code === 'Space' && spaceHeld) {
|
|
230
|
+
spaceHeld = false;
|
|
231
|
+
if (toolBeforeSpace) {
|
|
232
|
+
setTool(toolBeforeSpace);
|
|
233
|
+
toolBeforeSpace = null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Block browser zoom on Ctrl+wheel — engine's ZoomPan handles real zoom.
|
|
239
|
+
function handleWheel(e) {
|
|
240
|
+
if (e.ctrlKey || e.metaKey) e.preventDefault();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
244
|
+
document.addEventListener('keydown', handleSpaceDown);
|
|
245
|
+
document.addEventListener('keyup', handleKeyUp);
|
|
246
|
+
document.addEventListener('wheel', handleWheel, { passive: false });
|
|
247
|
+
|
|
248
|
+
return function uninstall() {
|
|
249
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
250
|
+
document.removeEventListener('keydown', handleSpaceDown);
|
|
251
|
+
document.removeEventListener('keyup', handleKeyUp);
|
|
252
|
+
document.removeEventListener('wheel', handleWheel);
|
|
253
|
+
};
|
|
254
|
+
}
|
package/src/core/CopyPaste.js
CHANGED
|
@@ -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
|
|
591
|
-
|
|
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
|
@@ -49,6 +49,27 @@ export function createSketchEngine(svgElement, options = {}) {
|
|
|
49
49
|
// the active engine's SVG. Both must be called after engine.init() completes.
|
|
50
50
|
export { saveScene, loadScene } from './core/SceneSerializer.js';
|
|
51
51
|
|
|
52
|
+
// Adaptive image compressor — same one the engine uses internally for
|
|
53
|
+
// uploads. Exported so embedded hosts can pre-compress images before
|
|
54
|
+
// shipping them across a postMessage boundary.
|
|
55
|
+
export { compressImage } from './utils/imageCompressor.js';
|
|
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
|
+
|
|
68
|
+
// Engine-local keyboard shortcuts (tool switching, delete, group/ungroup,
|
|
69
|
+
// space-to-pan, escape-deselect). App-level shortcuts (cloud save, modal
|
|
70
|
+
// toggles) are intentionally NOT included — those belong to the consumer.
|
|
71
|
+
export { installEngineShortcuts, SHORTCUT_MAP } from './EngineShortcuts.js';
|
|
72
|
+
|
|
52
73
|
// Tool name constants
|
|
53
74
|
export const TOOLS = {
|
|
54
75
|
SELECT: 'select',
|
package/src/tools/imageTool.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
//
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
imageToPlace =
|
|
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;
|
|
@@ -461,8 +489,11 @@ const handleMouseDownImage = async (e) => {
|
|
|
461
489
|
currentShape.isSelected = true;
|
|
462
490
|
placedShape.selectShape();
|
|
463
491
|
|
|
464
|
-
// Fire async upload pipeline
|
|
465
|
-
|
|
492
|
+
// Fire async upload pipeline. We dispatch via window.uploadImageToCloudinary
|
|
493
|
+
// so embedded hosts (e.g. blogs.elixpo) can swap in a postMessage-based
|
|
494
|
+
// implementation that routes uploads through the host's media API.
|
|
495
|
+
const uploader = (typeof window !== 'undefined' && window.uploadImageToCloudinary) || uploadImageToCloudinary;
|
|
496
|
+
uploader(imageShape).catch(err => {
|
|
466
497
|
console.warn('[ImageTool] Upload pipeline error:', err);
|
|
467
498
|
});
|
|
468
499
|
|
|
@@ -1283,7 +1314,7 @@ window.openImageFilePicker = function() {
|
|
|
1283
1314
|
isImageToolActive = true;
|
|
1284
1315
|
const fileInput = document.createElement('input');
|
|
1285
1316
|
fileInput.type = 'file';
|
|
1286
|
-
fileInput.accept =
|
|
1317
|
+
fileInput.accept = IMAGE_ACCEPT_ATTR;
|
|
1287
1318
|
fileInput.style.display = 'none';
|
|
1288
1319
|
document.body.appendChild(fileInput);
|
|
1289
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
|
+
}
|