@nyaruka/temba-components 0.156.5 → 0.156.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/CHANGELOG.md +17 -0
- package/dist/temba-components.js +1315 -1391
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/flow/CanvasNode.ts +1 -0
- package/src/flow/DragManager.ts +1239 -0
- package/src/flow/Editor.ts +467 -3329
- package/src/flow/IssuesWindow.ts +73 -0
- package/src/flow/RevisionsWindow.ts +274 -0
- package/src/flow/ZoomManager.ts +544 -0
- package/src/form/select/Select.ts +27 -0
- package/src/interfaces.ts +8 -1
- package/temba-modules.ts +4 -0
- package/run.sh +0 -19
- package/setup-worktree.sh +0 -53
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
import { FloatingTab } from '../display/FloatingTab';
|
|
2
|
+
import type { Editor } from './Editor';
|
|
3
|
+
|
|
4
|
+
export class ZoomManager {
|
|
5
|
+
// Zoom state
|
|
6
|
+
private zoomInitialized = false;
|
|
7
|
+
private zoomFitted = false;
|
|
8
|
+
|
|
9
|
+
// Loupe magnifier state
|
|
10
|
+
private loupeEl: HTMLElement | null = null;
|
|
11
|
+
private loupeContentEl: HTMLElement | null = null;
|
|
12
|
+
private loupeRAF: number | null = null;
|
|
13
|
+
private hiddenTitles: { el: Element; title: string }[] = [];
|
|
14
|
+
private loupeKeyHeld = false;
|
|
15
|
+
private loupeMouseIsDown = false;
|
|
16
|
+
private loupeLastMouse: { clientX: number; clientY: number } | null = null;
|
|
17
|
+
private loupeCloneTime = 0;
|
|
18
|
+
private loupeClone: HTMLElement | null = null;
|
|
19
|
+
private loupeCursorCanvas: { x: number; y: number } = { x: 0, y: 0 };
|
|
20
|
+
|
|
21
|
+
private static readonly LOUPE_DIAMETER = 280;
|
|
22
|
+
private static readonly LOUPE_CLONE_INTERVAL = 200;
|
|
23
|
+
|
|
24
|
+
// Bound loupe event handlers
|
|
25
|
+
private readonly boundLoupeMouseMove = this.handleLoupeMouseMove.bind(this);
|
|
26
|
+
private readonly boundLoupeMouseDown = this.handleLoupeMouseDown.bind(this);
|
|
27
|
+
private readonly boundLoupeMouseUp = this.handleLoupeMouseUp.bind(this);
|
|
28
|
+
private readonly boundLoupeKeyDown = this.handleLoupeKeyDown.bind(this);
|
|
29
|
+
private readonly boundLoupeKeyUp = this.handleLoupeKeyUp.bind(this);
|
|
30
|
+
|
|
31
|
+
constructor(private editor: Editor) {}
|
|
32
|
+
|
|
33
|
+
// --- Zoom ---
|
|
34
|
+
|
|
35
|
+
public setZoom(
|
|
36
|
+
newZoom: number,
|
|
37
|
+
center?: { clientX: number; clientY: number }
|
|
38
|
+
): void {
|
|
39
|
+
const clamped = Math.max(
|
|
40
|
+
0.3,
|
|
41
|
+
Math.min(1.0, Math.round(newZoom * 100) / 100)
|
|
42
|
+
);
|
|
43
|
+
if (clamped === this.editor.zoom) return;
|
|
44
|
+
|
|
45
|
+
const editor = this.editor.querySelector('#editor') as HTMLElement;
|
|
46
|
+
const oldZoom = this.editor.zoom;
|
|
47
|
+
this.editor.zoom = clamped;
|
|
48
|
+
this.editor.plumber.zoom = clamped;
|
|
49
|
+
this.zoomFitted = false;
|
|
50
|
+
this.editor.requestUpdate();
|
|
51
|
+
this.editor.saveFlowSetting('zoom', clamped);
|
|
52
|
+
|
|
53
|
+
if (editor && center) {
|
|
54
|
+
const editorRect = editor.getBoundingClientRect();
|
|
55
|
+
const ox = center.clientX - editorRect.left;
|
|
56
|
+
const oy = center.clientY - editorRect.top;
|
|
57
|
+
// Canvas point under cursor at old zoom
|
|
58
|
+
const cx = (editor.scrollLeft + ox) / oldZoom;
|
|
59
|
+
const cy = (editor.scrollTop + oy) / oldZoom;
|
|
60
|
+
|
|
61
|
+
requestAnimationFrame(() => {
|
|
62
|
+
editor.scrollLeft = cx * clamped - ox;
|
|
63
|
+
editor.scrollTop = cy * clamped - oy;
|
|
64
|
+
this.editor.plumber.repaintEverything();
|
|
65
|
+
});
|
|
66
|
+
} else {
|
|
67
|
+
requestAnimationFrame(() => this.editor.plumber.repaintEverything());
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public zoomIn(): void {
|
|
72
|
+
this.setZoom(this.editor.zoom + 0.05);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public zoomOut(): void {
|
|
76
|
+
this.setZoom(this.editor.zoom - 0.05);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
public zoomToFit(): void {
|
|
80
|
+
if (!this.editor.definition || this.editor.definition.nodes.length === 0)
|
|
81
|
+
return;
|
|
82
|
+
|
|
83
|
+
const editor = this.editor.querySelector('#editor') as HTMLElement;
|
|
84
|
+
if (!editor) return;
|
|
85
|
+
|
|
86
|
+
// Calculate bounding box of all content in canvas coordinates
|
|
87
|
+
let minX = Infinity;
|
|
88
|
+
let minY = Infinity;
|
|
89
|
+
let maxX = -Infinity;
|
|
90
|
+
let maxY = -Infinity;
|
|
91
|
+
|
|
92
|
+
this.editor.definition.nodes.forEach((node) => {
|
|
93
|
+
const ui = this.editor.definition._ui?.nodes[node.uuid];
|
|
94
|
+
if (!ui?.position) return;
|
|
95
|
+
const el = this.editor.querySelector(
|
|
96
|
+
`[id="${node.uuid}"]`
|
|
97
|
+
) as HTMLElement;
|
|
98
|
+
if (!el) return;
|
|
99
|
+
const w = el.offsetWidth;
|
|
100
|
+
const h = el.offsetHeight;
|
|
101
|
+
minX = Math.min(minX, ui.position.left);
|
|
102
|
+
minY = Math.min(minY, ui.position.top);
|
|
103
|
+
maxX = Math.max(maxX, ui.position.left + w);
|
|
104
|
+
maxY = Math.max(maxY, ui.position.top + h);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const stickies = this.editor.definition._ui?.stickies || {};
|
|
108
|
+
Object.entries(stickies).forEach(([uuid, sticky]) => {
|
|
109
|
+
if (!sticky.position) return;
|
|
110
|
+
const el = this.editor.querySelector(
|
|
111
|
+
`temba-sticky-note[uuid="${uuid}"]`
|
|
112
|
+
) as HTMLElement;
|
|
113
|
+
if (!el) return;
|
|
114
|
+
const w = el.offsetWidth;
|
|
115
|
+
const h = el.offsetHeight;
|
|
116
|
+
minX = Math.min(minX, sticky.position.left);
|
|
117
|
+
minY = Math.min(minY, sticky.position.top);
|
|
118
|
+
maxX = Math.max(maxX, sticky.position.left + w);
|
|
119
|
+
maxY = Math.max(maxY, sticky.position.top + h);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (minX === Infinity) return;
|
|
123
|
+
|
|
124
|
+
const contentWidth = maxX - minX;
|
|
125
|
+
const contentHeight = maxY - minY;
|
|
126
|
+
const padding = 40;
|
|
127
|
+
|
|
128
|
+
const availWidth = editor.clientWidth - padding * 2;
|
|
129
|
+
const availHeight = editor.clientHeight - padding * 2;
|
|
130
|
+
|
|
131
|
+
const scaleX = availWidth / contentWidth;
|
|
132
|
+
const scaleY = availHeight / contentHeight;
|
|
133
|
+
let fitZoom = Math.min(scaleX, scaleY, 1.0);
|
|
134
|
+
fitZoom = Math.max(fitZoom, 0.3);
|
|
135
|
+
fitZoom = Math.round(fitZoom * 20) / 20; // round to nearest 0.05
|
|
136
|
+
|
|
137
|
+
this.editor.zoom = fitZoom;
|
|
138
|
+
this.editor.plumber.zoom = fitZoom;
|
|
139
|
+
this.zoomFitted = true;
|
|
140
|
+
this.editor.requestUpdate();
|
|
141
|
+
this.editor.saveFlowSetting('zoom', fitZoom);
|
|
142
|
+
|
|
143
|
+
// Center of content in canvas coordinates, plus grid/canvas margin offset
|
|
144
|
+
const centerX = (minX + maxX) / 2 + 40;
|
|
145
|
+
const centerY = (minY + maxY) / 2 + 40;
|
|
146
|
+
|
|
147
|
+
requestAnimationFrame(() => {
|
|
148
|
+
editor.scrollLeft = centerX * fitZoom - editor.clientWidth / 2;
|
|
149
|
+
editor.scrollTop = centerY * fitZoom - editor.clientHeight / 2;
|
|
150
|
+
this.editor.plumber.repaintEverything();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
public zoomToFull(): void {
|
|
155
|
+
this.setZoom(1.0);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
public handleWheel(event: WheelEvent): void {
|
|
159
|
+
if (!event.ctrlKey && !event.metaKey) return;
|
|
160
|
+
event.preventDefault();
|
|
161
|
+
|
|
162
|
+
const delta = event.deltaY > 0 ? -0.05 : 0.05;
|
|
163
|
+
this.setZoom(this.editor.zoom + delta, {
|
|
164
|
+
clientX: event.clientX,
|
|
165
|
+
clientY: event.clientY
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
public restoreInitialZoomFromSettings(): void {
|
|
170
|
+
if (this.zoomInitialized || !this.editor.definition) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const savedZoom = this.editor.getFlowSetting<number>('zoom');
|
|
175
|
+
if (typeof savedZoom === 'number' && Number.isFinite(savedZoom)) {
|
|
176
|
+
const clamped = Math.max(
|
|
177
|
+
0.3,
|
|
178
|
+
Math.min(1.0, Math.round(savedZoom * 100) / 100)
|
|
179
|
+
);
|
|
180
|
+
this.editor.zoom = clamped;
|
|
181
|
+
if (this.editor.plumber) {
|
|
182
|
+
this.editor.plumber.zoom = clamped;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
this.zoomInitialized = true;
|
|
186
|
+
this.editor.requestUpdate();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Adjust floating tab positioning relative to toolbar and editor scrollbar */
|
|
190
|
+
public updateZoomControlPositioning(): void {
|
|
191
|
+
requestAnimationFrame(() => {
|
|
192
|
+
const editor = this.editor.querySelector('#editor') as HTMLElement;
|
|
193
|
+
if (editor) {
|
|
194
|
+
const scrollbarWidth = Math.max(
|
|
195
|
+
editor.offsetWidth - editor.clientWidth,
|
|
196
|
+
0
|
|
197
|
+
);
|
|
198
|
+
// Keep floating tabs just left of the vertical scrollbar.
|
|
199
|
+
document.documentElement.style.setProperty(
|
|
200
|
+
'--floating-tab-clip',
|
|
201
|
+
`${scrollbarWidth}px`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const toolbar = this.editor.querySelector(
|
|
206
|
+
'.editor-toolbar'
|
|
207
|
+
) as HTMLElement;
|
|
208
|
+
if (toolbar) {
|
|
209
|
+
const rect = toolbar.getBoundingClientRect();
|
|
210
|
+
FloatingTab.START_TOP = rect.bottom + 20;
|
|
211
|
+
FloatingTab.updateAllPositions();
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- Loupe magnifier ---
|
|
217
|
+
|
|
218
|
+
public initLoupe(): void {
|
|
219
|
+
document.addEventListener('mousemove', this.boundLoupeMouseMove);
|
|
220
|
+
document.addEventListener('keydown', this.boundLoupeKeyDown);
|
|
221
|
+
document.addEventListener('keyup', this.boundLoupeKeyUp);
|
|
222
|
+
document.addEventListener('mouseup', this.boundLoupeMouseUp);
|
|
223
|
+
// Capture-phase listener catches all mousedowns (including those where
|
|
224
|
+
// Plumber calls stopPropagation, e.g. exits and connection re-routing)
|
|
225
|
+
const editor = this.editor.querySelector('#editor') as HTMLElement;
|
|
226
|
+
if (editor) {
|
|
227
|
+
editor.addEventListener('mousedown', this.boundLoupeMouseDown, true);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
public teardownLoupe(): void {
|
|
232
|
+
document.removeEventListener('mousemove', this.boundLoupeMouseMove);
|
|
233
|
+
document.removeEventListener('keydown', this.boundLoupeKeyDown);
|
|
234
|
+
document.removeEventListener('keyup', this.boundLoupeKeyUp);
|
|
235
|
+
document.removeEventListener('mouseup', this.boundLoupeMouseUp);
|
|
236
|
+
const editor = this.editor.querySelector('#editor') as HTMLElement;
|
|
237
|
+
if (editor) {
|
|
238
|
+
editor.removeEventListener('mousedown', this.boundLoupeMouseDown, true);
|
|
239
|
+
}
|
|
240
|
+
this.hideLoupe();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
public setLoupeElements(
|
|
244
|
+
loupeEl: HTMLElement | null,
|
|
245
|
+
loupeContentEl: HTMLElement | null
|
|
246
|
+
): void {
|
|
247
|
+
this.loupeEl = loupeEl;
|
|
248
|
+
this.loupeContentEl = loupeContentEl;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
public isDialogOrMenuOpen(): boolean {
|
|
252
|
+
if (this.editor.editingNode || this.editor.editingAction) return true;
|
|
253
|
+
if (this.editor.deleteDialog?.open) return true;
|
|
254
|
+
const canvasMenu = this.editor.querySelector('temba-canvas-menu') as any;
|
|
255
|
+
if (canvasMenu?.open) return true;
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
public get isZoomFitted(): boolean {
|
|
260
|
+
return this.zoomFitted;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
public get isZoomInitialized(): boolean {
|
|
264
|
+
return this.zoomInitialized;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --- Private loupe helpers ---
|
|
268
|
+
|
|
269
|
+
private handleLoupeKeyDown(event: KeyboardEvent): void {
|
|
270
|
+
// Cmd+Ctrl+A (Mac) / Ctrl+Meta+A (Windows)
|
|
271
|
+
if (event.key.toLowerCase() !== 'a') return;
|
|
272
|
+
if (event.metaKey && event.ctrlKey) {
|
|
273
|
+
event.preventDefault();
|
|
274
|
+
this.loupeKeyHeld = true;
|
|
275
|
+
// Show loupe immediately at last known mouse position
|
|
276
|
+
if (this.loupeLastMouse) {
|
|
277
|
+
this.handleLoupeMouseMove(this.loupeLastMouse as MouseEvent);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private handleLoupeKeyUp(event: KeyboardEvent): void {
|
|
283
|
+
if (!this.loupeKeyHeld) return;
|
|
284
|
+
// Hide when any modifier is released
|
|
285
|
+
if (event.key === 'a' || event.key === 'Meta' || event.key === 'Control') {
|
|
286
|
+
this.loupeKeyHeld = false;
|
|
287
|
+
this.hideLoupe();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private handleLoupeMouseDown(): void {
|
|
292
|
+
this.loupeMouseIsDown = true;
|
|
293
|
+
this.hideLoupe();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private handleLoupeMouseUp(): void {
|
|
297
|
+
this.loupeMouseIsDown = false;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private handleLoupeMouseMove(event: MouseEvent): void {
|
|
301
|
+
this.loupeLastMouse = { clientX: event.clientX, clientY: event.clientY };
|
|
302
|
+
|
|
303
|
+
// Require Cmd+Ctrl+A held, hide while mouse is down, during interactions, or with dialogs open
|
|
304
|
+
if (
|
|
305
|
+
!this.loupeKeyHeld ||
|
|
306
|
+
this.loupeMouseIsDown ||
|
|
307
|
+
this.editor.isDragging ||
|
|
308
|
+
this.editor.isSelecting ||
|
|
309
|
+
this.editor.plumber?.connectionDragging ||
|
|
310
|
+
this.isDialogOrMenuOpen()
|
|
311
|
+
) {
|
|
312
|
+
this.hideLoupe();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Check if cursor is within the editor bounds
|
|
317
|
+
const editor = this.editor.querySelector('#editor') as HTMLElement;
|
|
318
|
+
if (!editor) return;
|
|
319
|
+
const rect = editor.getBoundingClientRect();
|
|
320
|
+
if (
|
|
321
|
+
event.clientX < rect.left ||
|
|
322
|
+
event.clientX > rect.right ||
|
|
323
|
+
event.clientY < rect.top ||
|
|
324
|
+
event.clientY > rect.bottom
|
|
325
|
+
) {
|
|
326
|
+
this.hideLoupe();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (this.loupeRAF) cancelAnimationFrame(this.loupeRAF);
|
|
331
|
+
this.loupeRAF = requestAnimationFrame(() => {
|
|
332
|
+
this.updateLoupe(event.clientX, event.clientY);
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private hideLoupe(): void {
|
|
337
|
+
if (this.loupeEl) {
|
|
338
|
+
this.loupeEl.classList.remove('visible');
|
|
339
|
+
}
|
|
340
|
+
this.restoreTitles();
|
|
341
|
+
if (this.loupeClone) {
|
|
342
|
+
this.loupeClone.remove();
|
|
343
|
+
this.loupeClone = null;
|
|
344
|
+
}
|
|
345
|
+
if (this.loupeRAF) {
|
|
346
|
+
cancelAnimationFrame(this.loupeRAF);
|
|
347
|
+
this.loupeRAF = null;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private suppressTitles(): void {
|
|
352
|
+
this.hiddenTitles = [];
|
|
353
|
+
const canvas = this.editor.querySelector('#canvas');
|
|
354
|
+
if (!canvas) return;
|
|
355
|
+
for (const el of canvas.querySelectorAll('[title]')) {
|
|
356
|
+
this.hiddenTitles.push({ el, title: el.getAttribute('title')! });
|
|
357
|
+
el.removeAttribute('title');
|
|
358
|
+
}
|
|
359
|
+
// Also check shadow DOMs of canvas nodes and sticky notes
|
|
360
|
+
for (const node of canvas.querySelectorAll(
|
|
361
|
+
'temba-canvas-node, temba-sticky-note'
|
|
362
|
+
)) {
|
|
363
|
+
if (node.shadowRoot) {
|
|
364
|
+
for (const el of node.shadowRoot.querySelectorAll('[title]')) {
|
|
365
|
+
this.hiddenTitles.push({ el, title: el.getAttribute('title')! });
|
|
366
|
+
el.removeAttribute('title');
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private restoreTitles(): void {
|
|
373
|
+
for (const { el, title } of this.hiddenTitles) {
|
|
374
|
+
el.setAttribute('title', title);
|
|
375
|
+
}
|
|
376
|
+
this.hiddenTitles = [];
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private rebuildLoupeClone(
|
|
380
|
+
canvas: HTMLElement,
|
|
381
|
+
canvasX: number,
|
|
382
|
+
canvasY: number,
|
|
383
|
+
visibleRadius: number
|
|
384
|
+
): void {
|
|
385
|
+
const contentEl = this.loupeContentEl;
|
|
386
|
+
if (!contentEl) return;
|
|
387
|
+
|
|
388
|
+
if (this.loupeClone) {
|
|
389
|
+
this.loupeClone.remove();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const clone = document.createElement('div');
|
|
393
|
+
clone.className = 'loupe-clone';
|
|
394
|
+
clone.style.width = `${canvas.scrollWidth}px`;
|
|
395
|
+
clone.style.height = `${canvas.scrollHeight}px`;
|
|
396
|
+
|
|
397
|
+
const pad = 50; // extra padding for partially visible elements
|
|
398
|
+
|
|
399
|
+
// Clone only nearby nodes (light DOM -- innerHTML captures rendered content)
|
|
400
|
+
const nodeEls = canvas.querySelectorAll('[data-node-uuid]');
|
|
401
|
+
for (const el of nodeEls) {
|
|
402
|
+
const htmlEl = el as HTMLElement;
|
|
403
|
+
const left = parseFloat(htmlEl.style.left) || 0;
|
|
404
|
+
const top = parseFloat(htmlEl.style.top) || 0;
|
|
405
|
+
const w = htmlEl.offsetWidth;
|
|
406
|
+
const h = htmlEl.offsetHeight;
|
|
407
|
+
|
|
408
|
+
// Bounding-box vs visible circle check
|
|
409
|
+
if (
|
|
410
|
+
left + w < canvasX - visibleRadius - pad ||
|
|
411
|
+
left > canvasX + visibleRadius + pad ||
|
|
412
|
+
top + h < canvasY - visibleRadius - pad ||
|
|
413
|
+
top > canvasY + visibleRadius + pad
|
|
414
|
+
)
|
|
415
|
+
continue;
|
|
416
|
+
|
|
417
|
+
// Wrap innerHTML in a plain div to avoid custom element upgrade
|
|
418
|
+
const div = document.createElement('div');
|
|
419
|
+
div.className = htmlEl.className;
|
|
420
|
+
div.style.cssText = htmlEl.style.cssText;
|
|
421
|
+
div.innerHTML = htmlEl.innerHTML;
|
|
422
|
+
clone.appendChild(div);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Clone SVG connections (standard elements, no upgrade issue)
|
|
426
|
+
const svgs = canvas.querySelectorAll('svg.plumb-connector');
|
|
427
|
+
for (const svg of svgs) {
|
|
428
|
+
clone.appendChild(svg.cloneNode(true));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Clone activity overlays
|
|
432
|
+
const overlays = canvas.querySelectorAll('.activity-overlay');
|
|
433
|
+
for (const overlay of overlays) {
|
|
434
|
+
clone.appendChild(overlay.cloneNode(true));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Clone sticky notes from their shadow DOM
|
|
438
|
+
const stickyEls = canvas.querySelectorAll('temba-sticky-note');
|
|
439
|
+
for (const el of stickyEls) {
|
|
440
|
+
const stickyEl = el as HTMLElement;
|
|
441
|
+
const sw = stickyEl.offsetWidth;
|
|
442
|
+
const sh = stickyEl.offsetHeight;
|
|
443
|
+
const left = parseFloat(stickyEl.style.left) || 0;
|
|
444
|
+
const top = parseFloat(stickyEl.style.top) || 0;
|
|
445
|
+
|
|
446
|
+
if (
|
|
447
|
+
left + sw < canvasX - visibleRadius - pad ||
|
|
448
|
+
left > canvasX + visibleRadius + pad ||
|
|
449
|
+
top + sh < canvasY - visibleRadius - pad ||
|
|
450
|
+
top > canvasY + visibleRadius + pad
|
|
451
|
+
)
|
|
452
|
+
continue;
|
|
453
|
+
|
|
454
|
+
if (!stickyEl.shadowRoot) continue;
|
|
455
|
+
|
|
456
|
+
const div = document.createElement('div');
|
|
457
|
+
div.className = stickyEl.className;
|
|
458
|
+
div.style.cssText = stickyEl.style.cssText;
|
|
459
|
+
// Extract adopted stylesheets from the shadow root (Lit uses these
|
|
460
|
+
// instead of inline <style> tags), scoping all rules under .loupe-sticky
|
|
461
|
+
// to prevent them from leaking into the light DOM
|
|
462
|
+
div.classList.add('loupe-sticky');
|
|
463
|
+
const sheets = stickyEl.shadowRoot.adoptedStyleSheets;
|
|
464
|
+
let cssText = '';
|
|
465
|
+
for (const sheet of sheets) {
|
|
466
|
+
for (const rule of sheet.cssRules) {
|
|
467
|
+
const ruleText = rule.cssText;
|
|
468
|
+
if (ruleText.startsWith(':host')) {
|
|
469
|
+
cssText += ruleText.replace(/:host/g, '.loupe-sticky') + '\n';
|
|
470
|
+
} else {
|
|
471
|
+
// Scope non-:host rules under .loupe-sticky
|
|
472
|
+
const braceIdx = ruleText.indexOf('{');
|
|
473
|
+
if (braceIdx !== -1) {
|
|
474
|
+
const selector = ruleText.substring(0, braceIdx).trim();
|
|
475
|
+
const body = ruleText.substring(braceIdx);
|
|
476
|
+
cssText += `.loupe-sticky ${selector} ${body}\n`;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
div.innerHTML =
|
|
482
|
+
`<style>${cssText}</style>` + stickyEl.shadowRoot.innerHTML;
|
|
483
|
+
clone.appendChild(div);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
contentEl.appendChild(clone);
|
|
487
|
+
this.loupeClone = clone;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private updateLoupe(clientX: number, clientY: number): void {
|
|
491
|
+
const loupeEl = this.loupeEl;
|
|
492
|
+
const contentEl = this.loupeContentEl;
|
|
493
|
+
if (!loupeEl || !contentEl || !this.editor.definition) return;
|
|
494
|
+
|
|
495
|
+
const canvas = this.editor.querySelector('#canvas') as HTMLElement;
|
|
496
|
+
if (!canvas) return;
|
|
497
|
+
const canvasRect = canvas.getBoundingClientRect();
|
|
498
|
+
|
|
499
|
+
// Canvas coordinates under cursor
|
|
500
|
+
const canvasX = (clientX - canvasRect.left) / this.editor.zoom;
|
|
501
|
+
const canvasY = (clientY - canvasRect.top) / this.editor.zoom;
|
|
502
|
+
|
|
503
|
+
const D = ZoomManager.LOUPE_DIAMETER;
|
|
504
|
+
const R = D / 2;
|
|
505
|
+
// Show content at a fixed comfortable scale inside the loupe
|
|
506
|
+
const loupeScale = Math.min(1.5, this.editor.zoom * 2.5);
|
|
507
|
+
const visibleRadius = R / loupeScale;
|
|
508
|
+
|
|
509
|
+
// Position loupe at cursor
|
|
510
|
+
loupeEl.style.left = `${clientX}px`;
|
|
511
|
+
loupeEl.style.top = `${clientY}px`;
|
|
512
|
+
loupeEl.classList.add('visible');
|
|
513
|
+
if (this.hiddenTitles.length === 0) {
|
|
514
|
+
this.suppressTitles();
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Grid background
|
|
518
|
+
const bgSize = 20 * loupeScale;
|
|
519
|
+
contentEl.style.backgroundSize = `${bgSize}px ${bgSize}px`;
|
|
520
|
+
contentEl.style.backgroundPosition = `${R - canvasX * loupeScale}px ${R - canvasY * loupeScale}px`;
|
|
521
|
+
|
|
522
|
+
// Rebuild clone periodically or when cursor has moved significantly
|
|
523
|
+
const now = performance.now();
|
|
524
|
+
const dx = canvasX - this.loupeCursorCanvas.x;
|
|
525
|
+
const dy = canvasY - this.loupeCursorCanvas.y;
|
|
526
|
+
const moved =
|
|
527
|
+
Math.abs(dx) > visibleRadius * 0.5 ||
|
|
528
|
+
Math.abs(dy) > visibleRadius * 0.5;
|
|
529
|
+
|
|
530
|
+
if (
|
|
531
|
+
!this.loupeClone ||
|
|
532
|
+
(now - this.loupeCloneTime > ZoomManager.LOUPE_CLONE_INTERVAL && moved)
|
|
533
|
+
) {
|
|
534
|
+
this.rebuildLoupeClone(canvas, canvasX, canvasY, visibleRadius);
|
|
535
|
+
this.loupeCloneTime = now;
|
|
536
|
+
this.loupeCursorCanvas = { x: canvasX, y: canvasY };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Position the clone so the canvas point under the cursor is at the loupe center
|
|
540
|
+
if (this.loupeClone) {
|
|
541
|
+
this.loupeClone.style.transform = `translate(${R - canvasX * loupeScale}px, ${R - canvasY * loupeScale}px) scale(${loupeScale})`;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
@@ -1721,6 +1721,31 @@ export class Select<T extends SelectOption> extends FieldElement {
|
|
|
1721
1721
|
}
|
|
1722
1722
|
}
|
|
1723
1723
|
|
|
1724
|
+
private handleBeforeInput(evt: InputEvent) {
|
|
1725
|
+
// Android virtual keyboards often don't fire keydown with key='Enter'.
|
|
1726
|
+
// Instead they fire beforeinput with inputType 'insertLineBreak' or
|
|
1727
|
+
// 'insertParagraph'. Prevent those from inserting newlines in the
|
|
1728
|
+
// contenteditable expression input, and then handle acceptable input
|
|
1729
|
+
// the same as Enter for tags/emails/expressions.
|
|
1730
|
+
if (
|
|
1731
|
+
evt.inputType === 'insertLineBreak' ||
|
|
1732
|
+
evt.inputType === 'insertParagraph'
|
|
1733
|
+
) {
|
|
1734
|
+
if (this.useExpressionInput) {
|
|
1735
|
+
evt.preventDefault();
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
if (
|
|
1739
|
+
this.completionOptions.length === 0 &&
|
|
1740
|
+
(this.emails || this.tags || this.expressions) &&
|
|
1741
|
+
this.isAcceptableInput(this.input)
|
|
1742
|
+
) {
|
|
1743
|
+
evt.preventDefault();
|
|
1744
|
+
this.addInputAsValue();
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1724
1749
|
private handleKeyDown(evt: KeyboardEvent) {
|
|
1725
1750
|
// Prevent Enter from inserting newlines in contenteditable
|
|
1726
1751
|
if (evt.key === 'Enter' && this.useExpressionInput) {
|
|
@@ -2143,6 +2168,7 @@ export class Select<T extends SelectOption> extends FieldElement {
|
|
|
2143
2168
|
spellcheck="false"
|
|
2144
2169
|
style=${styleMap(inputStyles)}
|
|
2145
2170
|
@input=${this.handleInput}
|
|
2171
|
+
@beforeinput=${this.handleBeforeInput}
|
|
2146
2172
|
@keydown=${this.handleKeyDown}
|
|
2147
2173
|
@click=${this.handleClick}
|
|
2148
2174
|
></div>`
|
|
@@ -2150,6 +2176,7 @@ export class Select<T extends SelectOption> extends FieldElement {
|
|
|
2150
2176
|
class="searchbox"
|
|
2151
2177
|
style=${styleMap(inputStyles)}
|
|
2152
2178
|
@input=${this.handleInput}
|
|
2179
|
+
@beforeinput=${this.handleBeforeInput}
|
|
2153
2180
|
@keydown=${this.handleKeyDown}
|
|
2154
2181
|
@click=${this.handleClick}
|
|
2155
2182
|
type="text"
|
package/src/interfaces.ts
CHANGED
|
@@ -315,5 +315,12 @@ export enum CustomEventType {
|
|
|
315
315
|
FlowClicked = 'temba-flow-clicked',
|
|
316
316
|
GroupClicked = 'temba-group-clicked',
|
|
317
317
|
ShowIssue = 'temba-show-issue',
|
|
318
|
-
SizeChanged = 'temba-size-changed'
|
|
318
|
+
SizeChanged = 'temba-size-changed',
|
|
319
|
+
IssueSelected = 'temba-issue-selected',
|
|
320
|
+
IssuesClosed = 'temba-issues-closed',
|
|
321
|
+
IssuesTabClicked = 'temba-issues-tab-clicked',
|
|
322
|
+
RevisionViewed = 'temba-revision-viewed',
|
|
323
|
+
RevisionCancelled = 'temba-revision-cancelled',
|
|
324
|
+
RevisionReverted = 'temba-revision-reverted',
|
|
325
|
+
RevisionsClosed = 'temba-revisions-closed'
|
|
319
326
|
}
|
package/temba-modules.ts
CHANGED
|
@@ -83,6 +83,8 @@ import { Accordion } from './src/layout/Accordion';
|
|
|
83
83
|
import { AccordionSection } from './src/layout/AccordionSection';
|
|
84
84
|
import { Simulator } from './src/simulator/Simulator';
|
|
85
85
|
import { FlowSearch } from './src/flow/FlowSearch';
|
|
86
|
+
import { IssuesWindow } from './src/flow/IssuesWindow';
|
|
87
|
+
import { RevisionsWindow } from './src/flow/RevisionsWindow';
|
|
86
88
|
import { MessageTable } from './src/flow/MessageTable';
|
|
87
89
|
|
|
88
90
|
export function addCustomElement(name: string, comp: any) {
|
|
@@ -178,3 +180,5 @@ addCustomElement('temba-floating-tab', FloatingTab);
|
|
|
178
180
|
addCustomElement('temba-floating-window', FloatingWindow);
|
|
179
181
|
addCustomElement('temba-simulator', Simulator);
|
|
180
182
|
addCustomElement('temba-flow-search', FlowSearch);
|
|
183
|
+
addCustomElement('temba-issues-window', IssuesWindow);
|
|
184
|
+
addCustomElement('temba-revisions-window', RevisionsWindow);
|
package/run.sh
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
|
|
3
|
-
CONTAINER_NAME="temba-components"
|
|
4
|
-
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
5
|
-
WORKSPACE_NAME="$(basename "$SCRIPT_DIR")"
|
|
6
|
-
CONTAINER_DIR="/workspaces/worktrees/temba-components/$WORKSPACE_NAME"
|
|
7
|
-
|
|
8
|
-
# Ensure the devcontainer is running, build if needed
|
|
9
|
-
if ! docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null | grep -q true; then
|
|
10
|
-
if docker inspect "$CONTAINER_NAME" &>/dev/null; then
|
|
11
|
-
echo "Container '$CONTAINER_NAME' is stopped — restarting..."
|
|
12
|
-
docker start "$CONTAINER_NAME"
|
|
13
|
-
else
|
|
14
|
-
echo "Container '$CONTAINER_NAME' not found — building devcontainer..."
|
|
15
|
-
devcontainer up --workspace-folder "$SCRIPT_DIR"
|
|
16
|
-
fi
|
|
17
|
-
fi
|
|
18
|
-
|
|
19
|
-
docker exec -w "$CONTAINER_DIR" -it "$CONTAINER_NAME" sh -c 'lsof -ti:3010 | xargs kill -9 2>/dev/null; pnpm '"$*"
|
package/setup-worktree.sh
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
|
|
3
|
-
# Sets up a temba-components worktree for development.
|
|
4
|
-
# Ensures the devcontainer is built/running and installs dependencies.
|
|
5
|
-
#
|
|
6
|
-
# Usage: ./setup-worktree.sh
|
|
7
|
-
|
|
8
|
-
set -e
|
|
9
|
-
|
|
10
|
-
CONTAINER_NAME="temba-components"
|
|
11
|
-
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
12
|
-
WORKSPACE_NAME="$(basename "$SCRIPT_DIR")"
|
|
13
|
-
|
|
14
|
-
# Ensure the devcontainer is running, build if needed
|
|
15
|
-
if ! docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null | grep -q true; then
|
|
16
|
-
if docker inspect "$CONTAINER_NAME" &>/dev/null; then
|
|
17
|
-
echo "Container '$CONTAINER_NAME' is stopped — restarting..."
|
|
18
|
-
docker start "$CONTAINER_NAME"
|
|
19
|
-
else
|
|
20
|
-
echo "Container '$CONTAINER_NAME' not found — building devcontainer..."
|
|
21
|
-
devcontainer up --workspace-folder "$SCRIPT_DIR"
|
|
22
|
-
fi
|
|
23
|
-
fi
|
|
24
|
-
|
|
25
|
-
# Install deps into a shared directory, then symlink into the worktree.
|
|
26
|
-
# This avoids a per-worktree pnpm install (~20s) on every new workspace.
|
|
27
|
-
docker exec "$CONTAINER_NAME" bash -c '
|
|
28
|
-
WORKTREE_DIR="/workspaces/worktrees/temba-components/'"$WORKSPACE_NAME"'"
|
|
29
|
-
DEPS_DIR="/workspaces/worktrees/.deps/temba-components"
|
|
30
|
-
mkdir -p "$DEPS_DIR"
|
|
31
|
-
|
|
32
|
-
# Install into shared deps dir if not done or lockfile has changed
|
|
33
|
-
LOCK_HASH=$(md5sum "$WORKTREE_DIR/pnpm-lock.yaml" | cut -d" " -f1)
|
|
34
|
-
CACHED_HASH=$(cat "$DEPS_DIR/.lock-hash" 2>/dev/null || echo "")
|
|
35
|
-
if [ "$LOCK_HASH" != "$CACHED_HASH" ]; then
|
|
36
|
-
echo "Installing shared dependencies..."
|
|
37
|
-
cp "$WORKTREE_DIR/package.json" "$WORKTREE_DIR/pnpm-lock.yaml" "$DEPS_DIR/"
|
|
38
|
-
cd "$DEPS_DIR"
|
|
39
|
-
pnpm install
|
|
40
|
-
echo "$LOCK_HASH" > "$DEPS_DIR/.lock-hash"
|
|
41
|
-
fi
|
|
42
|
-
|
|
43
|
-
# Symlink node_modules into the worktree
|
|
44
|
-
target="$WORKTREE_DIR/node_modules"
|
|
45
|
-
if [ -L "$target" ] && [ "$(readlink "$target")" = "$DEPS_DIR/node_modules" ]; then
|
|
46
|
-
: # already symlinked
|
|
47
|
-
else
|
|
48
|
-
rm -rf "$target"
|
|
49
|
-
ln -s "$DEPS_DIR/node_modules" "$target"
|
|
50
|
-
fi
|
|
51
|
-
|
|
52
|
-
echo "Worktree '\'''"$WORKSPACE_NAME"''\'' ready for development"
|
|
53
|
-
'
|