@mhmttosun/dnd 0.0.0-stage → 1.1.1

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,596 @@
1
+ /**
2
+ * UIManager - DND arayüzü, üst başlık, ikincil araç çubuğu,
3
+ * şablonlar menüsü, mod geçişleri ve bildirimleri yönetir.
4
+ * Yan paneller kaldırılmış, tam ekran tuval düzeni uygulanmıştır.
5
+ */
6
+ class UIManager {
7
+ constructor(core, stageManager, objectManager, selectionManager, historyManager, exportManager, options = {}) {
8
+ this.core = core;
9
+ this.stageManager = stageManager;
10
+ this.objectManager = objectManager;
11
+ this.selectionManager = selectionManager;
12
+ this.historyManager = historyManager;
13
+ this.exportManager = exportManager;
14
+ this.options = options;
15
+ this.root = options.root || document;
16
+
17
+ this.previewContainer = this.root.getElementById('preview-container');
18
+ this.editorContainer = this.root.getElementById('editor-layout');
19
+ this.activePlayerInstance = null;
20
+
21
+ this.init();
22
+ }
23
+
24
+ init() {
25
+ this.bindHeaderActions();
26
+ this.bindSubToolbar();
27
+ this.bindModeSwitcher();
28
+ this.bindProjectTitle();
29
+ this.bindInlineTextEditing();
30
+
31
+ this.core.on('historyChanged', ({ canUndo, canRedo }) => {
32
+ const undoBtn = this.root.getElementById('btn-undo');
33
+ const redoBtn = this.root.getElementById('btn-redo');
34
+ if (undoBtn) undoBtn.disabled = !canUndo;
35
+ if (redoBtn) redoBtn.disabled = !canRedo;
36
+ });
37
+
38
+ this.core.on('projectNameChanged', ({ name }) => {
39
+ const titleInput = this.root.getElementById('project-title-input');
40
+ if (titleInput && titleInput.value !== name) {
41
+ titleInput.value = name;
42
+ }
43
+ });
44
+
45
+ this.core.on('dataChanged', () => {
46
+ this.updateSaveStatus(false);
47
+ });
48
+
49
+ this.core.on('modeChanged', ({ newMode }) => {
50
+ this.handleModeChanged(newMode);
51
+ });
52
+ }
53
+
54
+ updateSaveStatus(isSaved) {
55
+ const statusBox = this.root.querySelector('.project-save-status');
56
+ const statusText = this.root.getElementById('status-text');
57
+ if (statusBox && statusText) {
58
+ if (isSaved) {
59
+ statusBox.classList.add('is-saved');
60
+ statusText.textContent = 'Kaydedildi';
61
+ } else {
62
+ statusBox.classList.remove('is-saved');
63
+ statusText.textContent = 'Kaydedilmedi';
64
+ }
65
+ }
66
+ }
67
+
68
+ bindHeaderActions() {
69
+ // 1. Proje Aç (JSON)
70
+ const btnOpen = this.root.getElementById('btn-open-project');
71
+ const fileInput = this.root.getElementById('file-input-project');
72
+ if (btnOpen && fileInput) {
73
+ btnOpen.onclick = () => fileInput.click();
74
+ fileInput.onchange = (e) => {
75
+ const file = e.target.files[0];
76
+ if (file) {
77
+ const reader = new FileReader();
78
+ reader.onload = (ev) => {
79
+ try {
80
+ const parsed = JSON.parse(ev.target.result);
81
+ this.core.loadProject(parsed);
82
+ this.updateSaveStatus(true);
83
+ this.showToast('Proje başarıyla yüklendi.', 'success');
84
+ } catch (err) {
85
+ alert('Proje dosyası geçersiz: ' + err.message);
86
+ }
87
+ };
88
+ reader.readAsText(file);
89
+ }
90
+ fileInput.value = '';
91
+ };
92
+ }
93
+
94
+ // 2. Projeyi Kaydet (JSON İndir)
95
+ const btnSave = this.root.getElementById('btn-save-project');
96
+ if (btnSave) {
97
+ btnSave.onclick = () => {
98
+ const projectData = this.core.getProjectData();
99
+ const jsonStr = JSON.stringify(projectData, null, 2);
100
+ const blob = new Blob([jsonStr], { type: 'application/json;charset=utf-8' });
101
+ const url = URL.createObjectURL(blob);
102
+
103
+ const safeTitle = ((projectData.project && projectData.project.name) || 'etkinlik')
104
+ .toLowerCase()
105
+ .replace(/[^a-z0-9ğüşıöç]+/g, '_');
106
+
107
+ const a = document.createElement('a');
108
+ a.href = url;
109
+ a.download = `${safeTitle}.json`;
110
+ document.body.appendChild(a);
111
+ a.click();
112
+ document.body.removeChild(a);
113
+ URL.revokeObjectURL(url);
114
+
115
+ this.updateSaveStatus(true);
116
+ this.showToast('Proje JSON olarak kaydedildi.', 'success');
117
+ };
118
+ }
119
+
120
+ // 3. HTML İndir
121
+ const btnExport = this.root.getElementById('btn-export-html');
122
+ if (btnExport) {
123
+ btnExport.onclick = () => {
124
+ const ok = this.exportManager.exportStandaloneHTML();
125
+ if (ok) {
126
+ this.showToast('Bağımsız HTML etkinliği indirildi!', 'success');
127
+ }
128
+ };
129
+ }
130
+ }
131
+
132
+ bindSubToolbar() {
133
+ // Şablonlar Menüsü Aç / Kapa
134
+ const btnTemplates = this.root.getElementById('btn-toggle-templates');
135
+ const menuTemplates = this.root.getElementById('templates-menu');
136
+ const btnMore = this.root.getElementById('btn-more-menu');
137
+ const menuMore = this.root.getElementById('more-dropdown-menu');
138
+
139
+ if (btnTemplates && menuTemplates) {
140
+ btnTemplates.onclick = (e) => {
141
+ e.stopPropagation();
142
+ if (menuMore) menuMore.style.display = 'none';
143
+ const isHidden = menuTemplates.style.display === 'none';
144
+ menuTemplates.style.display = isHidden ? 'flex' : 'none';
145
+ };
146
+
147
+ window.addEventListener('click', () => {
148
+ if (menuTemplates) menuTemplates.style.display = 'none';
149
+ }, this.options.signal ? { signal: this.options.signal } : undefined);
150
+
151
+ menuTemplates.querySelectorAll('[data-sample]').forEach(item => {
152
+ item.onclick = (e) => {
153
+ e.stopPropagation();
154
+ const sampleKey = item.getAttribute('data-sample');
155
+ const sample = window.DND.SAMPLE_PROJECTS[sampleKey];
156
+ if (sample) {
157
+ this.core.loadProject(sample);
158
+ this.updateSaveStatus(true);
159
+ this.showToast(`"${sample.project.name}" şablonu yüklendi.`, 'success');
160
+ }
161
+ menuTemplates.style.display = 'none';
162
+ };
163
+ });
164
+ }
165
+
166
+ // Daha Fazla Seçenek Menüsü Aç / Kapa (Proje Aç, Çevrimdışı HTML İndir, Kaydet)
167
+ if (btnMore && menuMore) {
168
+ btnMore.onclick = (e) => {
169
+ e.stopPropagation();
170
+ if (menuTemplates) menuTemplates.style.display = 'none';
171
+ const isHidden = menuMore.style.display === 'none';
172
+ menuMore.style.display = isHidden ? 'flex' : 'none';
173
+ btnMore.setAttribute('aria-expanded', String(isHidden));
174
+ };
175
+
176
+ window.addEventListener('click', () => {
177
+ if (menuMore) {
178
+ menuMore.style.display = 'none';
179
+ btnMore.setAttribute('aria-expanded', 'false');
180
+ }
181
+ }, this.options.signal ? { signal: this.options.signal } : undefined);
182
+
183
+ menuMore.querySelectorAll('.more-menu-item').forEach(item => {
184
+ item.addEventListener('click', () => {
185
+ menuMore.style.display = 'none';
186
+ btnMore.setAttribute('aria-expanded', 'false');
187
+ });
188
+ });
189
+ }
190
+
191
+ // Nesne Ekleme: Metin Kutusu
192
+ const btnAddText = this.root.getElementById('btn-add-text');
193
+ if (btnAddText) {
194
+ btnAddText.onclick = () => {
195
+ const obj = this.objectManager.createObject('text', 'decorative', {
196
+ text: 'Metin yazın',
197
+ x: 450,
198
+ y: 280,
199
+ width: 220,
200
+ height: 48,
201
+ fontSize: 18,
202
+ fontWeight: '600',
203
+ textColor: '#1e293b',
204
+ backgroundColor: 'transparent',
205
+ borderColor: 'transparent',
206
+ borderWidth: 0,
207
+ borderRadius: 4,
208
+ boxShadow: 'none',
209
+ padding: 4
210
+ });
211
+ this.selectionManager.select(obj.id);
212
+ };
213
+ }
214
+
215
+ // Nesne Ekleme: Dikdörtgen / Kart
216
+ const btnAddRect = this.root.getElementById('btn-add-rect');
217
+ if (btnAddRect) {
218
+ btnAddRect.onclick = () => {
219
+ const obj = this.objectManager.createObject('rectangle', 'decorative', {
220
+ text: '',
221
+ x: 420,
222
+ y: 240,
223
+ width: 240,
224
+ height: 60,
225
+ fontSize: 15,
226
+ fontWeight: '600',
227
+ backgroundColor: '#ffffff',
228
+ borderColor: '#f97316',
229
+ borderWidth: 2,
230
+ borderRadius: 12
231
+ });
232
+ this.selectionManager.select(obj.id);
233
+ };
234
+ }
235
+
236
+ // Nesne Ekleme: Daire
237
+ const btnAddCircle = this.root.getElementById('btn-add-circle');
238
+ if (btnAddCircle) {
239
+ btnAddCircle.onclick = () => {
240
+ const obj = this.objectManager.createObject('circle', 'decorative', {
241
+ text: '',
242
+ x: 480,
243
+ y: 240,
244
+ width: 100,
245
+ height: 100,
246
+ borderRadius: 9999,
247
+ backgroundColor: '#eff6ff',
248
+ borderColor: '#2563eb',
249
+ borderWidth: 2
250
+ });
251
+ this.selectionManager.select(obj.id);
252
+ };
253
+ }
254
+
255
+ // Nesne Ekleme: Görsel Yükle
256
+ const btnAddImage = this.root.getElementById('btn-add-image');
257
+ const imageInput = this.root.getElementById('file-input-image');
258
+ if (btnAddImage && imageInput) {
259
+ btnAddImage.onclick = () => imageInput.click();
260
+ imageInput.onchange = (e) => {
261
+ const file = e.target.files[0];
262
+ if (file) {
263
+ if (!file.type.startsWith('image/')) {
264
+ alert('Lütfen geçerli bir görsel dosyası seçin.');
265
+ return;
266
+ }
267
+ const task = (async () => {
268
+ const uploaded = typeof this.options.uploadImage === 'function'
269
+ ? await this.options.uploadImage(file, { signal: this.options.signal })
270
+ : null;
271
+ const dataUrl = uploaded?.url || await new Promise((resolve, reject) => {
272
+ const reader = new FileReader();
273
+ reader.onload = (ev) => resolve(ev.target.result);
274
+ reader.onerror = () => reject(reader.error || new Error('Görsel okunamadı.'));
275
+ reader.readAsDataURL(file);
276
+ });
277
+ const img = new Image();
278
+ await new Promise((resolve, reject) => {
279
+ img.onload = resolve;
280
+ img.onerror = () => reject(new Error('Görsel yüklenemedi.'));
281
+ img.src = dataUrl;
282
+ });
283
+ let w = img.width, h = img.height;
284
+ const maxDim = 320;
285
+ if (w > maxDim || h > maxDim) {
286
+ if (w > h) { h = Math.round((h * maxDim) / w); w = maxDim; }
287
+ else { w = Math.round((w * maxDim) / h); h = maxDim; }
288
+ }
289
+ const obj = this.objectManager.createObject('image', 'decorative', {
290
+ name: file.name.split('.')[0] || 'Görsel',
291
+ src: dataUrl,
292
+ x: 400,
293
+ y: 200,
294
+ width: w,
295
+ height: h,
296
+ text: '',
297
+ backgroundColor: 'transparent',
298
+ borderColor: 'transparent',
299
+ borderWidth: 0
300
+ });
301
+ this.selectionManager.select(obj.id);
302
+ })().catch((error) => {
303
+ this.showToast(error.message || 'Görsel yüklenemedi.', 'error');
304
+ throw error;
305
+ });
306
+ if (typeof this.options.trackTask === 'function') this.options.trackTask(task);
307
+ }
308
+ imageInput.value = '';
309
+ };
310
+ }
311
+
312
+ // Çizgi Ekleme (btn-add-target)
313
+ const btnAddTarget = this.root.getElementById('btn-add-target');
314
+ if (btnAddTarget) {
315
+ btnAddTarget.onclick = () => {
316
+ const obj = this.objectManager.createObject('line', 'decorative', {
317
+ x: 400,
318
+ y: 250,
319
+ width: 200,
320
+ height: 24,
321
+ borderColor: '#1e293b',
322
+ borderWidth: 3,
323
+ borderStyle: 'solid'
324
+ });
325
+ this.selectionManager.select(obj.id);
326
+ };
327
+ }
328
+
329
+ // Geri Al / İleri Al
330
+ const btnUndo = this.root.getElementById('btn-undo');
331
+ if (btnUndo) btnUndo.onclick = () => this.historyManager.undo();
332
+
333
+ const btnRedo = this.root.getElementById('btn-redo');
334
+ if (btnRedo) btnRedo.onclick = () => this.historyManager.redo();
335
+
336
+ // Yakınlaştır / Uzaklaştır / Sıfırla
337
+ const btnZoomIn = this.root.getElementById('btn-zoom-in');
338
+ if (btnZoomIn) btnZoomIn.onclick = () => this.stageManager.zoomIn();
339
+
340
+ const btnZoomOut = this.root.getElementById('btn-zoom-out');
341
+ if (btnZoomOut) btnZoomOut.onclick = () => this.stageManager.zoomOut();
342
+
343
+ const btnZoomReset = this.root.getElementById('btn-zoom-reset');
344
+ if (btnZoomReset) btnZoomReset.onclick = () => this.stageManager.fitToScreen();
345
+ }
346
+
347
+ bindModeSwitcher() {
348
+ const modeBtns = this.root.querySelectorAll('.mode-toggle-btn');
349
+ modeBtns.forEach(btn => {
350
+ btn.onclick = () => {
351
+ const mode = btn.getAttribute('data-mode');
352
+ this.core.setMode(mode);
353
+ };
354
+ });
355
+ }
356
+
357
+ handleModeChanged(newMode) {
358
+ this.root.querySelectorAll('.mode-toggle-btn').forEach(btn => {
359
+ btn.classList.toggle('active', btn.getAttribute('data-mode') === newMode);
360
+ });
361
+
362
+ const subToolbar = this.root.querySelector('.app-sub-toolbar');
363
+ if (subToolbar) {
364
+ subToolbar.classList.toggle('mode-answer-key', newMode === 'answer-key');
365
+ }
366
+
367
+ const clusterAdd = this.root.querySelector('.cluster-add-objects') || this.root.getElementById('cluster-add-objects');
368
+ const clusterHistoryZoom = this.root.querySelector('.cluster-history-zoom') || this.root.getElementById('cluster-history-zoom');
369
+ const templateDropdown = this.root.querySelector('.template-dropdown-wrapper');
370
+ if (clusterAdd) clusterAdd.style.display = newMode === 'answer-key' ? 'none' : 'flex';
371
+ if (clusterHistoryZoom) clusterHistoryZoom.style.display = newMode === 'answer-key' ? 'none' : 'flex';
372
+ if (templateDropdown) templateDropdown.style.display = newMode === 'answer-key' ? 'none' : '';
373
+ const moreMenuDivider = this.root.getElementById('more-menu-divider');
374
+ if (moreMenuDivider) moreMenuDivider.style.display = newMode === 'answer-key' ? 'none' : 'block';
375
+
376
+ if (newMode === 'preview') {
377
+ this.editorContainer.style.display = 'none';
378
+ this.previewContainer.style.display = 'block';
379
+
380
+ if (this.activePlayerInstance) {
381
+ this.activePlayerInstance.destroy();
382
+ }
383
+
384
+ this.activePlayerInstance = new window.DND.ActivityEngine(
385
+ this.previewContainer,
386
+ this.core.getProjectData(),
387
+ {
388
+ isEmbedded: true,
389
+ onExit: () => {
390
+ this.core.setMode('design');
391
+ }
392
+ }
393
+ );
394
+ } else {
395
+ if (this.activePlayerInstance) {
396
+ this.activePlayerInstance.destroy();
397
+ this.activePlayerInstance = null;
398
+ }
399
+
400
+ this.previewContainer.style.display = 'none';
401
+ this.editorContainer.style.display = 'flex';
402
+ this.stageManager.updateScale();
403
+ }
404
+ }
405
+
406
+ bindProjectTitle() {
407
+ const titleInput = this.root.getElementById('project-title-input');
408
+ if (titleInput) {
409
+ titleInput.oninput = (e) => {
410
+ this.core.setProjectName(e.target.value);
411
+ this.updateSaveStatus(false);
412
+ };
413
+ }
414
+ }
415
+
416
+ // Çift Tıklayarak veya Enter ile Yerinde Metin Düzenleme (Canva / Figma benzeri)
417
+ bindInlineTextEditing() {
418
+ this.isEditingText = false;
419
+ this.activeEditingObjId = null;
420
+
421
+ const stage = this.root.getElementById('stage');
422
+ if (!stage) return;
423
+
424
+ // 1. Çift Tıklama Olayı (Doğrudan nesneye veya seçim çerçevesine çift tıklandığında)
425
+ stage.addEventListener('dblclick', (e) => {
426
+ if (this.core.getMode() !== 'design') return;
427
+
428
+ let objId = null;
429
+ const objEl = e.target.closest('.stage-object');
430
+ if (objEl) {
431
+ objId = objEl.getAttribute('data-id');
432
+ } else if (e.target.closest('.selection-box, .selection-border')) {
433
+ const primary = this.selectionManager.getPrimarySelected();
434
+ if (primary) objId = primary.id;
435
+ }
436
+
437
+ if (!objId) return;
438
+ this.startInlineEditing(objId);
439
+ });
440
+
441
+ // 2. Çekirdek Olayı Dinleme (Enter tuşu veya MiniToolbar butonundan gelen tetikleme)
442
+ this.core.on('requestInlineTextEdit', (data) => {
443
+ if (data && data.objectId) {
444
+ this.startInlineEditing(data.objectId);
445
+ }
446
+ });
447
+
448
+ // 3. Dışarı Tıklamada Düzenlemeyi Tamamlama
449
+ window.addEventListener('pointerdown', (e) => {
450
+ if (!this.isEditingText) return;
451
+ // Eğer tıklama düzenlenen metin kutusu içindeyse veya mini araç çubuğundaysa kapatma
452
+ if (e.target.closest('.is-editing-text') || e.target.closest('#contextual-mini-toolbar')) {
453
+ return;
454
+ }
455
+ this.finishInlineEditing();
456
+ }, this.options.signal ? { capture: true, signal: this.options.signal } : true);
457
+ }
458
+
459
+ startInlineEditing(objId) {
460
+ const obj = this.objectManager.getObject(objId);
461
+ if (!obj || obj.locked || obj.type === 'line') return;
462
+
463
+ // Zaten bu nesne düzenleniyorsa tekrar başlatma
464
+ if (this.isEditingText && this.activeEditingObjId === objId) return;
465
+ if (this.isEditingText) {
466
+ this.finishInlineEditing();
467
+ }
468
+
469
+ const objEl = this.root.querySelector(`.stage-object[data-id="${objId}"]`);
470
+ if (!objEl) return;
471
+
472
+ const contentEl = objEl.querySelector('.object-inner-content');
473
+ if (!contentEl) return;
474
+
475
+ // Hedef metin öğesini bul veya oluştur
476
+ let targetEl = objEl.querySelector('.object-text-span, .image-caption, .category-header, .sequence-slot-label');
477
+ if (!targetEl) {
478
+ if (obj.type === 'image') {
479
+ targetEl = document.createElement('div');
480
+ targetEl.className = 'image-caption';
481
+ targetEl.style.width = '100%';
482
+ targetEl.style.textAlign = obj.textAlign || 'center';
483
+ targetEl.textContent = obj.text || '';
484
+ contentEl.appendChild(targetEl);
485
+ } else {
486
+ targetEl = document.createElement('span');
487
+ targetEl.className = 'object-text-span';
488
+ targetEl.style.width = '100%';
489
+ targetEl.style.display = 'block';
490
+ targetEl.style.textAlign = obj.textAlign || 'center';
491
+ targetEl.textContent = obj.text || '';
492
+ contentEl.appendChild(targetEl);
493
+ }
494
+ }
495
+
496
+ this.isEditingText = true;
497
+ this.activeEditingObjId = objId;
498
+
499
+ objEl.classList.add('is-editing-text');
500
+ targetEl.classList.add('is-editing-text-target');
501
+ targetEl.contentEditable = 'true';
502
+ targetEl.spellcheck = false;
503
+
504
+ // Seçim kutusunu düzenleme moduna al (tıklamaları metne geçir)
505
+ if (this.selectionManager && this.selectionManager.selectionOverlay) {
506
+ this.selectionManager.selectionOverlay.classList.add('is-editing-text');
507
+ }
508
+
509
+ // Odaklan ve tüm metni seç
510
+ targetEl.focus();
511
+ try {
512
+ const range = document.createRange();
513
+ range.selectNodeContents(targetEl);
514
+ const sel = window.getSelection();
515
+ sel.removeAllRanges();
516
+ sel.addRange(range);
517
+ } catch (err) {
518
+ // Güvenli devam
519
+ }
520
+
521
+ // Klavye kısayolları
522
+ targetEl.onkeydown = (ke) => {
523
+ if (ke.key === 'Escape') {
524
+ ke.preventDefault();
525
+ this.finishInlineEditing();
526
+ } else if (ke.key === 'Enter' && (ke.ctrlKey || ke.metaKey)) {
527
+ ke.preventDefault();
528
+ this.finishInlineEditing();
529
+ }
530
+ };
531
+ }
532
+
533
+ finishInlineEditing() {
534
+ if (!this.isEditingText || !this.activeEditingObjId) return;
535
+
536
+ const objId = this.activeEditingObjId;
537
+ const obj = this.objectManager.getObject(objId);
538
+ this.isEditingText = false;
539
+ this.activeEditingObjId = null;
540
+
541
+ const objEl = this.root.querySelector(`.stage-object[data-id="${objId}"]`);
542
+ if (objEl) {
543
+ objEl.classList.remove('is-editing-text');
544
+ const targetEl = objEl.querySelector('.is-editing-text-target') ||
545
+ objEl.querySelector('.object-text-span, .image-caption, .category-header, .sequence-slot-label');
546
+
547
+ if (targetEl) {
548
+ targetEl.contentEditable = 'false';
549
+ targetEl.classList.remove('is-editing-text-target');
550
+ targetEl.onkeydown = null;
551
+
552
+ // Metin içeriğini temizleyip güncelle
553
+ let rawText = targetEl.innerText !== undefined ? targetEl.innerText : targetEl.textContent;
554
+ // Sondaki gereksiz fazladan boşluk veya satırları normalize et
555
+ let newText = rawText.replace(/\r\n/g, '\n').replace(/\n+$/, '');
556
+ if (obj && ['rectangle', 'circle', 'image'].includes(obj.type) && !newText.trim()) {
557
+ newText = '';
558
+ }
559
+
560
+ if (obj && obj.text !== newText) {
561
+ this.objectManager.updateObject(objId, { text: newText });
562
+ } else if (obj && ['rectangle', 'circle', 'image'].includes(obj.type) && newText === '') {
563
+ targetEl.remove();
564
+ }
565
+ }
566
+ }
567
+
568
+ // Seçim kutusunu normale döndür
569
+ if (this.selectionManager && this.selectionManager.selectionOverlay) {
570
+ this.selectionManager.selectionOverlay.classList.remove('is-editing-text');
571
+ this.selectionManager.updateSelectionBox();
572
+ }
573
+ }
574
+
575
+ showToast(message, type = 'info', duration = 3000) {
576
+ let toast = this.root.getElementById('app-toast');
577
+ if (!toast) {
578
+ toast = document.createElement('div');
579
+ toast.id = 'app-toast';
580
+ toast.className = 'app-toast';
581
+ (this.root.body || this.root).appendChild(toast);
582
+ }
583
+
584
+ toast.className = `app-toast toast-${type} is-visible`;
585
+ toast.textContent = message;
586
+
587
+ clearTimeout(this.toastTimeout);
588
+ this.toastTimeout = setTimeout(() => {
589
+ toast.classList.remove('is-visible');
590
+ }, duration);
591
+ }
592
+ }
593
+
594
+ // Global namespace
595
+ window.DND = window.DND || {};
596
+ window.DND.UIManager = UIManager;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * AudioSynthesizer - Tarayıcı Web Audio API kullanarak harici ses dosyalarına
3
+ * ihtiyaç duymadan temiz, profesyonel ses efektleri üretir.
4
+ * Tamamen çevrimdışı çalışır.
5
+ */
6
+ class AudioSynthesizer {
7
+ constructor() {
8
+ this.ctx = null;
9
+ this.enabled = true;
10
+ }
11
+
12
+ init() {
13
+ if (!this.ctx && (window.AudioContext || window.webkitAudioContext)) {
14
+ const AudioCtx = window.AudioContext || window.webkitAudioContext;
15
+ this.ctx = new AudioCtx();
16
+ }
17
+ if (this.ctx && this.ctx.state === 'suspended') {
18
+ this.ctx.resume();
19
+ }
20
+ }
21
+
22
+ setEnabled(val) {
23
+ this.enabled = !!val;
24
+ }
25
+
26
+ playTone(freq, type, duration, delay = 0, gainLevel = 0.15) {
27
+ if (!this.enabled) return;
28
+ try {
29
+ this.init();
30
+ if (!this.ctx) return;
31
+
32
+ const startTime = this.ctx.currentTime + delay;
33
+ const osc = this.ctx.createOscillator();
34
+ const gain = this.ctx.createGain();
35
+
36
+ osc.type = type;
37
+ osc.frequency.setValueAtTime(freq, startTime);
38
+
39
+ gain.gain.setValueAtTime(gainLevel, startTime);
40
+ gain.gain.exponentialRampToValueAtTime(0.0001, startTime + duration);
41
+
42
+ osc.connect(gain);
43
+ gain.connect(this.ctx.destination);
44
+
45
+ osc.start(startTime);
46
+ osc.stop(startTime + duration);
47
+ } catch (e) {
48
+ // Ses hatası sessizce geçilir
49
+ }
50
+ }
51
+
52
+ // Doğru cevap sesi: Neşeli, yükselen majör akor (C5, E5, G5)
53
+ playCorrect() {
54
+ if (!this.enabled) return;
55
+ this.playTone(523.25, 'triangle', 0.12, 0, 0.18); // C5
56
+ this.playTone(659.25, 'triangle', 0.14, 0.08, 0.18); // E5
57
+ this.playTone(783.99, 'triangle', 0.25, 0.16, 0.22); // G5
58
+ }
59
+
60
+ // Yanlış cevap sesi: Yumuşak alçalan uyarı tonu
61
+ playWrong() {
62
+ if (!this.enabled) return;
63
+ this.playTone(320, 'sine', 0.12, 0, 0.15);
64
+ this.playTone(260, 'sine', 0.22, 0.09, 0.15);
65
+ }
66
+
67
+ // Nesneyi eline alma / tıklama çıt sesi
68
+ playPickup() {
69
+ if (!this.enabled) return;
70
+ this.playTone(480, 'sine', 0.05, 0, 0.08);
71
+ }
72
+
73
+ // Nesneyi bırakma sesi
74
+ playDrop() {
75
+ if (!this.enabled) return;
76
+ this.playTone(360, 'sine', 0.07, 0, 0.1);
77
+ }
78
+
79
+ // Etkinlik tamamlandığında kutlama fanfarı
80
+ playComplete() {
81
+ if (!this.enabled) return;
82
+ const notes = [
83
+ { f: 523.25, d: 0.12, t: 0 }, // C5
84
+ { f: 659.25, d: 0.12, t: 0.1 }, // E5
85
+ { f: 783.99, d: 0.14, t: 0.2 }, // G5
86
+ { f: 1046.50, d: 0.45, t: 0.32 } // C6
87
+ ];
88
+ notes.forEach(n => {
89
+ this.playTone(n.f, 'triangle', n.d, n.t, 0.2);
90
+ });
91
+ }
92
+
93
+ // Web Audio Context kapatma ve bellek temizliği (Lifecycle Destroy)
94
+ close() {
95
+ if (this.ctx && typeof this.ctx.close === 'function') {
96
+ try {
97
+ this.ctx.close();
98
+ } catch (e) {}
99
+ this.ctx = null;
100
+ }
101
+ }
102
+ }
103
+
104
+ // Universal namespace export
105
+ if (typeof module !== 'undefined' && module.exports) {
106
+ module.exports = { AudioSynthesizer };
107
+ }
108
+ if (typeof window !== 'undefined') {
109
+ window.DND = window.DND || {};
110
+ window.DND.AudioSynthesizer = AudioSynthesizer;
111
+ }