@elixpo/lixsketch 5.4.4 → 5.4.6
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/index.js +16 -0
- package/src/tools/imageTool.js +5 -2
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/index.js
CHANGED
|
@@ -43,6 +43,22 @@ export function createSketchEngine(svgElement, options = {}) {
|
|
|
43
43
|
return new SketchEngine(svgElement, options);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// Scene serialization helpers — for embedded consumers (e.g. blogs.elixpo) that
|
|
47
|
+
// need to round-trip a scene through their own storage. saveScene reads from
|
|
48
|
+
// window.shapes (populated by an active engine); loadScene rebuilds shapes onto
|
|
49
|
+
// the active engine's SVG. Both must be called after engine.init() completes.
|
|
50
|
+
export { saveScene, loadScene } from './core/SceneSerializer.js';
|
|
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
|
+
// Engine-local keyboard shortcuts (tool switching, delete, group/ungroup,
|
|
58
|
+
// space-to-pan, escape-deselect). App-level shortcuts (cloud save, modal
|
|
59
|
+
// toggles) are intentionally NOT included — those belong to the consumer.
|
|
60
|
+
export { installEngineShortcuts, SHORTCUT_MAP } from './EngineShortcuts.js';
|
|
61
|
+
|
|
46
62
|
// Tool name constants
|
|
47
63
|
export const TOOLS = {
|
|
48
64
|
SELECT: 'select',
|
package/src/tools/imageTool.js
CHANGED
|
@@ -461,8 +461,11 @@ const handleMouseDownImage = async (e) => {
|
|
|
461
461
|
currentShape.isSelected = true;
|
|
462
462
|
placedShape.selectShape();
|
|
463
463
|
|
|
464
|
-
// Fire async upload pipeline
|
|
465
|
-
|
|
464
|
+
// Fire async upload pipeline. We dispatch via window.uploadImageToCloudinary
|
|
465
|
+
// so embedded hosts (e.g. blogs.elixpo) can swap in a postMessage-based
|
|
466
|
+
// implementation that routes uploads through the host's media API.
|
|
467
|
+
const uploader = (typeof window !== 'undefined' && window.uploadImageToCloudinary) || uploadImageToCloudinary;
|
|
468
|
+
uploader(imageShape).catch(err => {
|
|
466
469
|
console.warn('[ImageTool] Upload pipeline error:', err);
|
|
467
470
|
});
|
|
468
471
|
|