rails-realtime-erd 0.1.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,640 @@
1
+ (function() {
2
+ "use strict";
3
+
4
+ if (!window.Stimulus || !window.Stimulus.Application) {
5
+ console.error("[rails-realtime-erd] Stimulus is not loaded — aborting controller registration.");
6
+ return;
7
+ }
8
+ var Application = window.Stimulus.Application;
9
+ var Controller = window.Stimulus.Controller;
10
+
11
+ var application = Application.start();
12
+ application.warnings = true;
13
+ application.debug = false;
14
+ window.RailsRealtimeErdStimulus = application;
15
+
16
+ /* ========================== filter_controller ========================== */
17
+ class FilterController extends Controller {
18
+ static targets = [
19
+ "search", "modelList", "modelRow", "modelCheckbox",
20
+ "previewRelations", "showRelationComment", "showKey", "showComment", "hideColumns", "showOnlyKeys",
21
+ "showKeyLabel", "showCommentLabel", "showOnlyKeysLabel"
22
+ ];
23
+
24
+ connect() {
25
+ this.state = {
26
+ selectModels: [],
27
+ isPreviewRelations: false,
28
+ isShowRelationComment: false,
29
+ isShowKey: false,
30
+ isShowComment: false,
31
+ isHideColumns: false,
32
+ isShowOnlyKeys: false,
33
+ filterText: ""
34
+ };
35
+ this.syncDom();
36
+ this.applyHideColumnsDisabling();
37
+ this.broadcast();
38
+ }
39
+
40
+ allModelNames() {
41
+ return this.modelRowTargets.map(row => row.dataset.modelName);
42
+ }
43
+
44
+ applyFromState(newState) {
45
+ this.state = { ...this.state, ...newState };
46
+ this.syncDom();
47
+ this.applyHideColumnsDisabling();
48
+ this.broadcast();
49
+ }
50
+
51
+ syncDom() {
52
+ this.modelCheckboxTargets.forEach(cb => {
53
+ cb.checked = this.state.selectModels.includes(cb.value);
54
+ });
55
+ if (this.hasPreviewRelationsTarget) this.previewRelationsTarget.checked = !!this.state.isPreviewRelations;
56
+ if (this.hasShowRelationCommentTarget) this.showRelationCommentTarget.checked = !!this.state.isShowRelationComment;
57
+ if (this.hasShowKeyTarget) this.showKeyTarget.checked = !!this.state.isShowKey;
58
+ if (this.hasShowCommentTarget) this.showCommentTarget.checked = !!this.state.isShowComment;
59
+ if (this.hasHideColumnsTarget) this.hideColumnsTarget.checked = !!this.state.isHideColumns;
60
+ if (this.hasShowOnlyKeysTarget) this.showOnlyKeysTarget.checked = !!this.state.isShowOnlyKeys;
61
+ if (this.hasSearchTarget) this.searchTarget.value = this.state.filterText;
62
+ this.applySearchFilter();
63
+ }
64
+
65
+ applySearchFilter() {
66
+ const q = (this.state.filterText || "").toLowerCase();
67
+ this.modelRowTargets.forEach(row => {
68
+ const name = (row.dataset.modelName || "").toLowerCase();
69
+ const table = (row.dataset.tableName || "").toLowerCase();
70
+ const visible = !q || name.includes(q) || table.includes(q);
71
+ if (visible) {
72
+ row.removeAttribute("data-rre-hidden");
73
+ } else {
74
+ row.setAttribute("data-rre-hidden", "");
75
+ }
76
+ });
77
+ }
78
+
79
+ applyHideColumnsDisabling() {
80
+ const disabled = !!this.state.isHideColumns;
81
+ const inputs = [
82
+ this.hasShowKeyTarget && this.showKeyTarget,
83
+ this.hasShowCommentTarget && this.showCommentTarget,
84
+ this.hasShowOnlyKeysTarget && this.showOnlyKeysTarget
85
+ ].filter(Boolean);
86
+ inputs.forEach(input => { input.disabled = disabled; });
87
+ const labels = [
88
+ this.hasShowKeyLabelTarget && this.showKeyLabelTarget,
89
+ this.hasShowCommentLabelTarget && this.showCommentLabelTarget,
90
+ this.hasShowOnlyKeysLabelTarget && this.showOnlyKeysLabelTarget
91
+ ].filter(Boolean);
92
+ labels.forEach(label => {
93
+ if (disabled) {
94
+ label.classList.add("cursor-not-allowed", "opacity-60");
95
+ label.classList.remove("cursor-pointer");
96
+ } else {
97
+ label.classList.remove("cursor-not-allowed", "opacity-60");
98
+ label.classList.add("cursor-pointer");
99
+ }
100
+ });
101
+ }
102
+
103
+ onModelToggle(event) {
104
+ const value = event.target.value;
105
+ if (event.target.checked) {
106
+ if (!this.state.selectModels.includes(value)) {
107
+ this.state.selectModels = [...this.state.selectModels, value];
108
+ }
109
+ } else {
110
+ this.state.selectModels = this.state.selectModels.filter(m => m !== value);
111
+ }
112
+ this.broadcast();
113
+ }
114
+
115
+ onOptionChange() {
116
+ this.state = {
117
+ ...this.state,
118
+ isPreviewRelations: this.hasPreviewRelationsTarget && this.previewRelationsTarget.checked,
119
+ isShowRelationComment: this.hasShowRelationCommentTarget && this.showRelationCommentTarget.checked,
120
+ isShowKey: this.hasShowKeyTarget && this.showKeyTarget.checked,
121
+ isShowComment: this.hasShowCommentTarget && this.showCommentTarget.checked,
122
+ isHideColumns: this.hasHideColumnsTarget && this.hideColumnsTarget.checked,
123
+ isShowOnlyKeys: this.hasShowOnlyKeysTarget && this.showOnlyKeysTarget.checked
124
+ };
125
+ this.applyHideColumnsDisabling();
126
+ this.broadcast();
127
+ }
128
+
129
+ onSearchChange(event) {
130
+ this.state.filterText = event.target.value || "";
131
+ this.applySearchFilter();
132
+ }
133
+
134
+ selectAll() {
135
+ const visible = this.modelRowTargets.filter(row => !row.hasAttribute("data-rre-hidden"));
136
+ visible.forEach(row => {
137
+ const name = row.dataset.modelName;
138
+ if (!this.state.selectModels.includes(name)) {
139
+ this.state.selectModels = [...this.state.selectModels, name];
140
+ }
141
+ });
142
+ this.syncDom();
143
+ this.broadcast();
144
+ }
145
+
146
+ unselectAll() {
147
+ const visible = this.modelRowTargets.filter(row => !row.hasAttribute("data-rre-hidden"));
148
+ const toRemove = new Set(visible.map(r => r.dataset.modelName));
149
+ this.state.selectModels = this.state.selectModels.filter(m => !toRemove.has(m));
150
+ this.syncDom();
151
+ this.broadcast();
152
+ }
153
+
154
+ reset() {
155
+ this.state = {
156
+ selectModels: [],
157
+ isPreviewRelations: false,
158
+ isShowRelationComment: false,
159
+ isShowKey: false,
160
+ isShowComment: false,
161
+ isHideColumns: false,
162
+ isShowOnlyKeys: false,
163
+ filterText: ""
164
+ };
165
+ this.syncDom();
166
+ this.applyHideColumnsDisabling();
167
+ this.broadcast();
168
+ }
169
+
170
+ broadcast() {
171
+ this.dispatch("changed", { detail: { ...this.state }, bubbles: true });
172
+ }
173
+ }
174
+
175
+ /* ========================== diagram_controller ========================== */
176
+ class DiagramController extends Controller {
177
+ static targets = ["preview", "codeOutput"];
178
+
179
+ connect() {
180
+ const dataNode = document.getElementById("rre-schema-data");
181
+ let schema = { Models: [], Relations: [] };
182
+ if (dataNode) {
183
+ try { schema = JSON.parse(dataNode.textContent); } catch (_) {}
184
+ }
185
+ this.schema = {
186
+ Models: (schema.Models || []).slice().sort((a, b) => a.ModelName < b.ModelName ? -1 : 1),
187
+ Relations: schema.Relations || []
188
+ };
189
+ this.lastState = null;
190
+ this.mermaidReady = this.initMermaid();
191
+ this.boundFilterChanged = (e) => this.onFilterChanged(e.detail);
192
+ document.addEventListener("filter:changed", this.boundFilterChanged);
193
+ }
194
+
195
+ disconnect() {
196
+ document.removeEventListener("filter:changed", this.boundFilterChanged);
197
+ }
198
+
199
+ async initMermaid() {
200
+ if (typeof window.mermaid === "undefined") return;
201
+ window.mermaid.initialize({
202
+ startOnLoad: false,
203
+ theme: "dark",
204
+ securityLevel: "loose",
205
+ maxTextSize: 99999999
206
+ });
207
+ }
208
+
209
+ onFilterChanged(state) {
210
+ this.lastState = state;
211
+ this.render();
212
+ }
213
+
214
+ computeCode(state) {
215
+ const lines = [];
216
+ lines.push("erDiagram");
217
+ lines.push(" %% --------------------------------------------------------");
218
+ lines.push(" %% Generated by \"Rails Realtime ERD\"");
219
+ lines.push(` %% Restore Hash: ${location.hash || ""}`);
220
+ lines.push(" %% --------------------------------------------------------");
221
+ lines.push("");
222
+
223
+ const data = this.filteredData(state);
224
+
225
+ data.Models.forEach(model => {
226
+ lines.push(` %% table name: ${model.TableName}`);
227
+ lines.push(` %% table comment: ${model.TableComment}`);
228
+ lines.push(` ${model.ModelName.replace(/:/g, "-")} {`);
229
+
230
+ if (!state.isHideColumns) {
231
+ model.Columns.forEach(column => {
232
+ if (state.isShowOnlyKeys && !column.key) return;
233
+ const key = state.isShowKey ? (column.key || "") : "";
234
+ const comment = state.isShowComment ? `"${column.comment || ""}"` : "";
235
+ lines.push(` ${column.type} ${column.name} ${key} ${comment}`);
236
+ });
237
+ }
238
+ lines.push(" }");
239
+ lines.push("");
240
+ });
241
+
242
+ data.Relations.forEach(relation => {
243
+ const comment = state.isShowRelationComment ? `"${relation.Comment}"` : `""`;
244
+ lines.push(` ${relation.LeftModelName.replace(/:/g, "-")} ${relation.LeftValue}${relation.Line}${relation.RightValue} ${relation.RightModelName.replace(/:/g, "-")} : ${comment}`);
245
+ });
246
+
247
+ return lines.join("\n");
248
+ }
249
+
250
+ filteredData(state) {
251
+ const selected = new Set(state.selectModels || []);
252
+ let relations;
253
+ if (state.isPreviewRelations) {
254
+ relations = this.schema.Relations.filter(r => selected.has(r.LeftModelName) || selected.has(r.RightModelName));
255
+ } else {
256
+ relations = this.schema.Relations.filter(r => selected.has(r.LeftModelName) && selected.has(r.RightModelName));
257
+ }
258
+ return {
259
+ Models: this.schema.Models.filter(m => selected.has(m.ModelName)),
260
+ Relations: relations
261
+ };
262
+ }
263
+
264
+ currentCode() {
265
+ if (!this.lastState) return "";
266
+ return this.computeCode(this.lastState);
267
+ }
268
+
269
+ currentSvg() {
270
+ if (!this.hasPreviewTarget) return "";
271
+ const svg = this.previewTarget.querySelector("svg");
272
+ return svg ? new XMLSerializer().serializeToString(svg) : "";
273
+ }
274
+
275
+ async render() {
276
+ if (!this.lastState) return;
277
+ const code = this.computeCode(this.lastState);
278
+ if (this.hasCodeOutputTarget) this.codeOutputTarget.value = code;
279
+
280
+ await this.mermaidReady;
281
+ if (typeof window.mermaid === "undefined" || !this.hasPreviewTarget) return;
282
+ try {
283
+ const renderId = `mermaid-erd-${Date.now()}`;
284
+ const result = await window.mermaid.render(renderId, code);
285
+ const svg = (result && result.svg) ? result.svg : result;
286
+ this.previewTarget.innerHTML = svg;
287
+ } catch (err) {
288
+ console.error("[rails-realtime-erd] mermaid render failed", err);
289
+ this.previewTarget.innerHTML = `<pre class="text-red-300 p-4 text-xs">${(err && err.message) || err}</pre>`;
290
+ }
291
+ }
292
+ }
293
+
294
+ /* ========================== hash_state_controller ========================== */
295
+ class HashStateController extends Controller {
296
+ static outlets = ["filter"];
297
+
298
+ connect() {
299
+ this.boundHashChange = () => this.restore();
300
+ window.addEventListener("hashchange", this.boundHashChange);
301
+ document.addEventListener("filter:changed", this.boundFilterChanged = (e) => this.writeHash(e.detail));
302
+ this.tryRestoreOnce();
303
+ }
304
+
305
+ disconnect() {
306
+ window.removeEventListener("hashchange", this.boundHashChange);
307
+ document.removeEventListener("filter:changed", this.boundFilterChanged);
308
+ }
309
+
310
+ filterOutletConnected(outlet) {
311
+ this.tryRestoreOnce(outlet);
312
+ }
313
+
314
+ tryRestoreOnce(outlet) {
315
+ const target = outlet || (this.hasFilterOutlet ? this.filterOutlet : null);
316
+ if (!target || this.restored) return;
317
+ this.restored = true;
318
+ this.restore(target);
319
+ }
320
+
321
+ restore(outlet) {
322
+ const target = outlet || (this.hasFilterOutlet ? this.filterOutlet : null);
323
+ if (!target) return;
324
+ if (!location.hash || location.hash.length < 2) return;
325
+ try {
326
+ const parsed = JSON.parse(atob(location.hash.substring(1)));
327
+ target.applyFromState({
328
+ selectModels: parsed.selectModels || [],
329
+ isPreviewRelations: !!parsed.isPreviewRelations,
330
+ isShowRelationComment: !!parsed.isShowRelationComment,
331
+ isShowKey: !!parsed.isShowKey,
332
+ isShowComment: !!parsed.isShowComment,
333
+ isHideColumns: !!parsed.isHideColumns,
334
+ isShowOnlyKeys: !!parsed.isShowOnlyKeys
335
+ });
336
+ } catch (_) { /* ignore */ }
337
+ }
338
+
339
+ writeHash(state) {
340
+ if (!state) return;
341
+ const payload = {
342
+ selectModels: state.selectModels,
343
+ isPreviewRelations: state.isPreviewRelations,
344
+ isShowRelationComment: state.isShowRelationComment,
345
+ isShowKey: state.isShowKey,
346
+ isShowComment: state.isShowComment,
347
+ isHideColumns: state.isHideColumns,
348
+ isShowOnlyKeys: state.isShowOnlyKeys
349
+ };
350
+ const encoded = btoa(JSON.stringify(payload));
351
+ history.replaceState(null, "", `#${encoded}`);
352
+ }
353
+ }
354
+
355
+ /* ========================== clipboard_controller ========================== */
356
+ class ClipboardController extends Controller {
357
+ static targets = ["copyUrlButton", "copyUrlLabel", "copyMermaidButton", "copyMermaidLabel", "copyMarkdownButton", "copyMarkdownLabel"];
358
+ static outlets = ["diagram"];
359
+
360
+ async copyUrl() {
361
+ await this.write(location.href);
362
+ this.flash(this.copyUrlLabelTarget, "Copy Link for Sharing", "Copied Link for Sharing");
363
+ }
364
+
365
+ async copyMermaid() {
366
+ const code = this.hasDiagramOutlet ? this.diagramOutlet.currentCode() : "";
367
+ await this.write(code);
368
+ this.flash(this.copyMermaidLabelTarget, "Copy Mermaid Code", "Copied Mermaid Code");
369
+ }
370
+
371
+ async copyMarkdown() {
372
+ const code = this.hasDiagramOutlet ? this.diagramOutlet.currentCode() : "";
373
+ await this.write("```mermaid\n" + code + "\n```\n");
374
+ this.flash(this.copyMarkdownLabelTarget, "Copy Markdown Code", "Copied Markdown Code");
375
+ }
376
+
377
+ async write(text) {
378
+ if (!text) return;
379
+ try { await navigator.clipboard.writeText(text); } catch (_) { /* ignore */ }
380
+ }
381
+
382
+ flash(labelEl, idleText, busyText) {
383
+ if (!labelEl) return;
384
+ labelEl.textContent = busyText;
385
+ setTimeout(() => { labelEl.textContent = idleText; }, 1000);
386
+ }
387
+ }
388
+
389
+ /* ========================== download_controller ========================== */
390
+ class DownloadController extends Controller {
391
+ static outlets = ["diagram"];
392
+
393
+ downloadSvg() {
394
+ if (!this.hasDiagramOutlet) return;
395
+ const svgString = this.diagramOutlet.currentSvg();
396
+ if (!svgString) return;
397
+ const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
398
+ const url = URL.createObjectURL(blob);
399
+ this.triggerDownload(url, "rails_realtime_erd.svg");
400
+ URL.revokeObjectURL(url);
401
+ }
402
+
403
+ downloadPng() {
404
+ if (!this.hasDiagramOutlet) return;
405
+ const svgEl = this.diagramOutlet.previewTarget.querySelector("svg");
406
+ if (!svgEl) return;
407
+ const svgString = new XMLSerializer().serializeToString(svgEl);
408
+ const canvas = document.createElement("canvas");
409
+ const width = svgEl.viewBox && svgEl.viewBox.baseVal && svgEl.viewBox.baseVal.width
410
+ ? svgEl.viewBox.baseVal.width
411
+ : (svgEl.width.baseVal ? svgEl.width.baseVal.value : svgEl.clientWidth);
412
+ const height = svgEl.viewBox && svgEl.viewBox.baseVal && svgEl.viewBox.baseVal.height
413
+ ? svgEl.viewBox.baseVal.height
414
+ : (svgEl.height.baseVal ? svgEl.height.baseVal.value : svgEl.clientHeight);
415
+ canvas.width = width || 800;
416
+ canvas.height = height || 600;
417
+ const ctx = canvas.getContext("2d");
418
+ const image = new Image();
419
+ image.onload = () => {
420
+ ctx.drawImage(image, 0, 0);
421
+ this.triggerDownload(canvas.toDataURL("image/png"), "rails_realtime_erd.png");
422
+ };
423
+ image.src = "data:image/svg+xml;charset=utf-8;base64," + btoa(unescape(encodeURIComponent(svgString)));
424
+ }
425
+
426
+ triggerDownload(href, filename) {
427
+ const a = document.createElement("a");
428
+ a.href = href;
429
+ a.setAttribute("download", filename);
430
+ a.dispatchEvent(new MouseEvent("click"));
431
+ }
432
+ }
433
+
434
+ /* ========================== tab_controller ========================== */
435
+ class TabController extends Controller {
436
+ static targets = ["erdButton", "codeButton", "erdPane", "codePane"];
437
+
438
+ showErd() { this.show("erd"); }
439
+ showCode() { this.show("code"); }
440
+
441
+ show(which) {
442
+ const erdActive = which === "erd";
443
+ this.erdPaneTarget.toggleAttribute("data-rre-hidden", !erdActive);
444
+ this.codePaneTarget.toggleAttribute("data-rre-hidden", erdActive);
445
+
446
+ if (erdActive) {
447
+ this.erdButtonTarget.classList.remove("bg-gray-400");
448
+ this.erdButtonTarget.classList.add("bg-white");
449
+ this.codeButtonTarget.classList.remove("bg-white");
450
+ this.codeButtonTarget.classList.add("bg-gray-400");
451
+ } else {
452
+ this.erdButtonTarget.classList.remove("bg-white");
453
+ this.erdButtonTarget.classList.add("bg-gray-400");
454
+ this.codeButtonTarget.classList.remove("bg-gray-400");
455
+ this.codeButtonTarget.classList.add("bg-white");
456
+ }
457
+ }
458
+ }
459
+
460
+ /* ========================== zoom_pan_controller ========================== */
461
+ class ZoomPanController extends Controller {
462
+ static targets = ["canvas", "area"];
463
+
464
+ connect() {
465
+ this.scale = 1;
466
+ this.posX = 0;
467
+ this.posY = 0;
468
+ this.spacePressed = false;
469
+ this.dragging = false;
470
+ this.lastTouchDistance = 0;
471
+ this.lastMouseX = 0;
472
+ this.lastMouseY = 0;
473
+
474
+ this.boundKeyDown = (e) => this.onKeyDown(e);
475
+ this.boundKeyUp = (e) => this.onKeyUp(e);
476
+ this.boundMouseDown = (e) => this.onMouseDown(e);
477
+ this.boundMouseMove = (e) => this.onMouseMove(e);
478
+ this.boundMouseUp = (e) => this.onMouseUp(e);
479
+ this.boundWheel = (e) => this.onWheel(e);
480
+ this.boundTouchStart = (e) => this.onTouchStart(e);
481
+ this.boundTouchMove = (e) => this.onTouchMove(e);
482
+ this.boundTouchEnd = (e) => this.onTouchEnd(e);
483
+
484
+ window.addEventListener("keydown", this.boundKeyDown);
485
+ window.addEventListener("keyup", this.boundKeyUp);
486
+ window.addEventListener("mousedown", this.boundMouseDown);
487
+ window.addEventListener("mousemove", this.boundMouseMove);
488
+ window.addEventListener("mouseup", this.boundMouseUp);
489
+ window.addEventListener("wheel", this.boundWheel, { passive: false });
490
+ window.addEventListener("touchstart", this.boundTouchStart, { passive: false });
491
+ window.addEventListener("touchmove", this.boundTouchMove, { passive: false });
492
+ window.addEventListener("touchend", this.boundTouchEnd);
493
+
494
+ this.applyTransform();
495
+ }
496
+
497
+ disconnect() {
498
+ window.removeEventListener("keydown", this.boundKeyDown);
499
+ window.removeEventListener("keyup", this.boundKeyUp);
500
+ window.removeEventListener("mousedown", this.boundMouseDown);
501
+ window.removeEventListener("mousemove", this.boundMouseMove);
502
+ window.removeEventListener("mouseup", this.boundMouseUp);
503
+ window.removeEventListener("wheel", this.boundWheel);
504
+ window.removeEventListener("touchstart", this.boundTouchStart);
505
+ window.removeEventListener("touchmove", this.boundTouchMove);
506
+ window.removeEventListener("touchend", this.boundTouchEnd);
507
+ }
508
+
509
+ preview() { return document.getElementById("rre-preview"); }
510
+
511
+ isInsidePreview(target) {
512
+ const preview = this.preview();
513
+ return preview && preview.contains(target);
514
+ }
515
+
516
+ applyTransform() {
517
+ if (!this.hasAreaTarget) return;
518
+ this.areaTarget.style.transform = `scale(${this.scale}) translate(${this.posX}px, ${this.posY}px)`;
519
+ }
520
+
521
+ onKeyDown(e) {
522
+ if (e.code === "Space" && !this.spacePressed) {
523
+ if (document.activeElement && (document.activeElement.tagName === "INPUT" || document.activeElement.tagName === "TEXTAREA")) return;
524
+ this.spacePressed = true;
525
+ this.element.setAttribute("data-zoom-pan-space-pressed", "true");
526
+ e.preventDefault();
527
+ }
528
+ }
529
+
530
+ onKeyUp(e) {
531
+ if (e.code === "Space") {
532
+ this.spacePressed = false;
533
+ this.dragging = false;
534
+ this.element.removeAttribute("data-zoom-pan-space-pressed");
535
+ this.element.removeAttribute("data-zoom-pan-dragging");
536
+ }
537
+ }
538
+
539
+ onMouseDown(e) {
540
+ if (this.spacePressed || e.button === 1) {
541
+ this.dragging = true;
542
+ this.lastMouseX = e.clientX;
543
+ this.lastMouseY = e.clientY;
544
+ this.element.setAttribute("data-zoom-pan-dragging", "true");
545
+ e.preventDefault();
546
+ }
547
+ }
548
+
549
+ onMouseMove(e) {
550
+ if (this.dragging) {
551
+ const dx = (e.clientX - this.lastMouseX) / this.scale;
552
+ const dy = (e.clientY - this.lastMouseY) / this.scale;
553
+ this.posX += dx;
554
+ this.posY += dy;
555
+ this.lastMouseX = e.clientX;
556
+ this.lastMouseY = e.clientY;
557
+ this.applyTransform();
558
+ e.preventDefault();
559
+ }
560
+ }
561
+
562
+ onMouseUp(e) {
563
+ if (e.button === 1) e.preventDefault();
564
+ this.dragging = false;
565
+ this.element.removeAttribute("data-zoom-pan-dragging");
566
+ }
567
+
568
+ onWheel(e) {
569
+ if (!this.isInsidePreview(e.target)) return;
570
+ e.preventDefault();
571
+ const preview = this.preview();
572
+ const rect = preview.getBoundingClientRect();
573
+ const mouseX = e.clientX - rect.left;
574
+ const mouseY = e.clientY - rect.top;
575
+ const x = mouseX / this.scale - this.posX;
576
+ const y = mouseY / this.scale - this.posY;
577
+ const delta = e.deltaY < 0 ? 0.1 : -0.1;
578
+ const newScale = Math.min(Math.max(this.scale + delta, 0.5), 3);
579
+ this.posX = mouseX / newScale - x;
580
+ this.posY = mouseY / newScale - y;
581
+ this.scale = newScale;
582
+ this.applyTransform();
583
+ }
584
+
585
+ touchDistance(touches) {
586
+ return Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY);
587
+ }
588
+ touchCenter(touches) {
589
+ return { x: (touches[0].clientX + touches[1].clientX) / 2, y: (touches[0].clientY + touches[1].clientY) / 2 };
590
+ }
591
+
592
+ onTouchStart(e) {
593
+ if (e.touches.length === 2) {
594
+ e.preventDefault();
595
+ this.lastTouchDistance = this.touchDistance(e.touches);
596
+ }
597
+ }
598
+ onTouchMove(e) {
599
+ if (e.touches.length !== 2) return;
600
+ e.preventDefault();
601
+ const preview = this.preview();
602
+ if (!preview || !preview.contains(e.target)) return;
603
+ const newDistance = this.touchDistance(e.touches);
604
+ const delta = (newDistance - this.lastTouchDistance) * 0.01;
605
+ this.lastTouchDistance = newDistance;
606
+ const center = this.touchCenter(e.touches);
607
+ const rect = preview.getBoundingClientRect();
608
+ const tx = center.x - rect.left;
609
+ const ty = center.y - rect.top;
610
+ const x = tx / this.scale - this.posX;
611
+ const y = ty / this.scale - this.posY;
612
+ const newScale = Math.min(Math.max(this.scale + delta, 0.5), 3);
613
+ if (newScale === this.scale) return;
614
+ this.posX = tx / newScale - x;
615
+ this.posY = ty / newScale - y;
616
+ this.scale = newScale;
617
+ this.applyTransform();
618
+ }
619
+ onTouchEnd(e) {
620
+ if (e.touches.length < 2) this.lastTouchDistance = 0;
621
+ }
622
+
623
+ zoomIn() { this.scale = Math.min(this.scale + 0.1, 3); this.applyTransform(); }
624
+ zoomOut() { this.scale = Math.max(this.scale - 0.1, 0.5); this.applyTransform(); }
625
+ move(dx, dy) { const step = 10 / this.scale; this.posX += dx * step; this.posY += dy * step; this.applyTransform(); }
626
+ moveUp() { this.move(0, -10); }
627
+ moveDown() { this.move(0, 10); }
628
+ moveLeft() { this.move(-10, 0); }
629
+ moveRight() { this.move(10, 0); }
630
+ }
631
+
632
+ application.register("filter", FilterController);
633
+ application.register("diagram", DiagramController);
634
+ application.register("hash-state", HashStateController);
635
+ application.register("clipboard", ClipboardController);
636
+ application.register("download", DownloadController);
637
+ application.register("tab", TabController);
638
+ application.register("zoom-pan", ZoomPanController);
639
+
640
+ })();