@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,494 @@
1
+ /**
2
+ * AnswerKeyManager - Görsel cevap anahtarı sistemi.
3
+ * Sürüklenebilir nesneler ile hedef alanlar arasındaki ilişkileri
4
+ * görsel SVG bağlantı çizgileriyle kurar, düzenler ve veri modeline işler.
5
+ */
6
+ class AnswerKeyManager {
7
+ constructor(core, stageManager, objectManager, stageEl) {
8
+ this.core = core;
9
+ this.stageManager = stageManager;
10
+ this.objectManager = objectManager;
11
+ this.stage = stageEl;
12
+
13
+ this.svgConnections = stageEl.querySelector('#svg-connections');
14
+ this.activeSourceId = null; // Bağlantı için seçilen sürüklenebilir nesne
15
+ this.guideBar = null;
16
+
17
+ this.init();
18
+ }
19
+
20
+ init() {
21
+ this.core.on('modeChanged', ({ newMode }) => {
22
+ this.activeSourceId = null;
23
+ this.stage.classList.remove('ak-selecting-target');
24
+ if (newMode === 'answer-key') {
25
+ this.renderConnections();
26
+ this.updateGuideBar();
27
+ setTimeout(() => this.stageManager.updateScale(), 0);
28
+ } else {
29
+ this.clearSvg();
30
+ this.updateGuideBar();
31
+ setTimeout(() => this.stageManager.updateScale(), 0);
32
+ }
33
+ });
34
+
35
+ this.core.on('projectLoaded', () => {
36
+ this.cleanupDanglingKeys();
37
+ if (this.core.getMode() === 'answer-key') this.renderConnections();
38
+ this.updateGuideBar();
39
+ });
40
+
41
+ this.core.on('objectDeleted', () => {
42
+ this.cleanupDanglingKeys();
43
+ if (this.core.getMode() === 'answer-key') this.renderConnections();
44
+ this.updateGuideBar();
45
+ });
46
+
47
+ this.core.on('objectUpdated', () => {
48
+ if (this.core.getMode() === 'answer-key') this.renderConnections();
49
+ this.updateGuideBar();
50
+ });
51
+
52
+ this.core.on('objectAdded', () => {
53
+ this.updateGuideBar();
54
+ });
55
+
56
+ this.core.on('answerKeyChanged', () => {
57
+ this.updateGuideBar();
58
+ });
59
+
60
+ this.setupClickInteraction();
61
+ this.updateGuideBar();
62
+ }
63
+
64
+ getGuideBar() {
65
+ if (this.guideBar && this.guideBar.isConnected) return this.guideBar;
66
+ const root = (this.stage && this.stage.getRootNode && this.stage.getRootNode() !== document)
67
+ ? this.stage.getRootNode()
68
+ : document;
69
+ this.guideBar = (root.getElementById ? root.getElementById('answer-key-guide-bar') : null)
70
+ || (root.querySelector ? root.querySelector('#answer-key-guide-bar') : null)
71
+ || document.getElementById('answer-key-guide-bar');
72
+ return this.guideBar;
73
+ }
74
+
75
+ getGuideState() {
76
+ const objects = this.core.data.objects || [];
77
+ const answerKey = this.core.data.answerKey || {};
78
+ const draggables = objects.filter(o => o.role === 'draggable');
79
+ const targets = objects.filter(o => ['target', 'category', 'sequence_slot'].includes(o.role));
80
+ const targetIds = new Set(targets.map(t => t.id));
81
+
82
+ const totalDraggables = draggables.length;
83
+ let matchedCount = 0;
84
+ draggables.forEach(d => {
85
+ const tId = answerKey[d.id];
86
+ if (tId && (targetIds.has(tId) || this.core.data.project?.activityType === 'sequencing')) {
87
+ matchedCount++;
88
+ }
89
+ });
90
+
91
+ // 1. Sahne açıldığında, tüm eşleştirmeler yapılmışsa:
92
+ // Eşleştirmeler tamamlandı (Tüm eşleşmeler tamamlandı) / X/Y eşleşme · Cevap anahtarını kontrol et
93
+ // 2. Eşleştirmeler hiç yapılmamışsa:
94
+ // Cevap anahtarı oluştur / 0/Y eşleşme · Seçerek eşleştirmeye başla
95
+ // 3. Eşleştirme eksikse:
96
+ // Eşleştirmeye devam et / X/Y eşleşme · Sıradaki nesneyi seçerek devam et.
97
+ if (totalDraggables > 0 && matchedCount >= totalDraggables) {
98
+ return {
99
+ status: 'all-completed',
100
+ title: 'Eşleştirmeler tamamlandı', // Tüm eşleşmeler tamamlandı
101
+ instruction: `${matchedCount}/${totalDraggables} eşleşme · Cevap anahtarını kontrol et`, // Cevap anahtarını kontrol edebilirsin
102
+ matchedCount,
103
+ totalDraggables
104
+ };
105
+ } else if (matchedCount === 0) {
106
+ return {
107
+ status: 'none-completed',
108
+ title: 'Cevap anahtarı oluştur',
109
+ instruction: this.activeSourceId
110
+ ? `${matchedCount}/${totalDraggables} eşleşme · Hedef nesneyi seçerek eşleştirmeyi tamamla`
111
+ : `${matchedCount}/${totalDraggables} eşleşme · Seçerek eşleştirmeye başla`,
112
+ matchedCount,
113
+ totalDraggables
114
+ };
115
+ } else {
116
+ return {
117
+ status: 'incomplete',
118
+ title: 'Eşleştirmeye devam et',
119
+ instruction: this.activeSourceId
120
+ ? `${matchedCount}/${totalDraggables} eşleşme · Hedef nesneyi seçerek eşleştirmeyi tamamla`
121
+ : `${matchedCount}/${totalDraggables} eşleşme · Sıradaki nesneyi seçerek devam et.`,
122
+ matchedCount,
123
+ totalDraggables
124
+ };
125
+ }
126
+ }
127
+
128
+ updateGuideBar() {
129
+ const bar = this.getGuideBar();
130
+ if (!bar) return;
131
+
132
+ const isAnswerKey = this.core.getMode() === 'answer-key';
133
+ bar.style.display = isAnswerKey ? 'flex' : 'none';
134
+
135
+ if (!isAnswerKey) return;
136
+
137
+ const state = this.getGuideState();
138
+
139
+ const titleEl = bar.querySelector('#ak-guide-title');
140
+ const instructionEl = bar.querySelector('#ak-guide-instruction');
141
+ const iconContainer = bar.querySelector('#ak-status-icon');
142
+
143
+ if (titleEl) titleEl.textContent = state.title;
144
+ if (instructionEl) instructionEl.textContent = state.instruction;
145
+
146
+ if (iconContainer) {
147
+ if (state.status === 'all-completed') {
148
+ iconContainer.innerHTML = `
149
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
150
+ <circle cx="12" cy="12" r="10"/>
151
+ <polyline points="9 12 12 15 16 10"/>
152
+ </svg>`;
153
+ } else if (state.status === 'incomplete') {
154
+ iconContainer.innerHTML = `
155
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
156
+ <circle cx="12" cy="12" r="10"/>
157
+ <polyline points="12 6 12 12 16 14"/>
158
+ </svg>`;
159
+ } else {
160
+ iconContainer.innerHTML = `
161
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
162
+ <circle cx="12" cy="12" r="10"/>
163
+ <line x1="12" y1="8" x2="12" y2="16"/>
164
+ <line x1="8" y1="12" x2="16" y2="12"/>
165
+ </svg>`;
166
+ }
167
+ }
168
+
169
+ bar.setAttribute('data-guide-state', state.status);
170
+ }
171
+
172
+ setupClickInteraction() {
173
+ // Cevap anahtarı modunda nesnelere tıklama ile eşleştirme
174
+ this.stage.addEventListener('click', (e) => {
175
+ if (this.core.getMode() !== 'answer-key') return;
176
+
177
+ const objEl = e.target.closest('.stage-object');
178
+ if (!objEl) {
179
+ // Boşluğa tıklanırsa seçili kaynağı bırak
180
+ if (this.activeSourceId) {
181
+ this.activeSourceId = null;
182
+ this.updateVisualHighlights();
183
+ this.updateGuideBar();
184
+ }
185
+ return;
186
+ }
187
+
188
+ const id = objEl.getAttribute('data-id');
189
+ const obj = this.objectManager.getObject(id);
190
+ if (!obj) return;
191
+
192
+ e.stopPropagation();
193
+
194
+ // 1. Durum: Sürüklenebilir bir nesneye tıklandıysa
195
+ if (obj.role === 'draggable') {
196
+ if (this.activeSourceId === id) {
197
+ // Zaten seçiliyse seçimi kaldır
198
+ this.activeSourceId = null;
199
+ } else {
200
+ // Yeni kaynak seçildi
201
+ this.activeSourceId = id;
202
+ }
203
+ this.updateVisualHighlights();
204
+ this.updateGuideBar();
205
+ return;
206
+ }
207
+
208
+ // 2. Durum: Kaynak seçiliyken sadece sahne rolü "Hedef" olan nesneler tıklanabilir olmalı
209
+ if (this.activeSourceId) {
210
+ const isTarget = ['target', 'category', 'sequence_slot'].includes(obj.role);
211
+
212
+ if (isTarget) {
213
+ this.setAnswer(this.activeSourceId, obj.id);
214
+ this.activeSourceId = null;
215
+ this.updateVisualHighlights();
216
+ this.renderConnections();
217
+ this.updateGuideBar();
218
+ }
219
+ }
220
+ });
221
+ }
222
+
223
+ setAnswer(draggableId, targetId) {
224
+ if (!this.core.data.answerKey) {
225
+ this.core.data.answerKey = {};
226
+ }
227
+
228
+ this.core.data.answerKey[draggableId] = targetId;
229
+ this.core.emit('answerKeyChanged', {
230
+ draggableId,
231
+ targetId,
232
+ answerKey: this.core.data.answerKey
233
+ });
234
+ this.core.emit('dataChanged', { source: 'answerKey' });
235
+ this.core.autoSave();
236
+ this.updateGuideBar();
237
+ }
238
+
239
+ removeAnswer(draggableId) {
240
+ if (this.core.data.answerKey && this.core.data.answerKey[draggableId]) {
241
+ delete this.core.data.answerKey[draggableId];
242
+ this.core.emit('answerKeyChanged', {
243
+ draggableId,
244
+ targetId: null,
245
+ answerKey: this.core.data.answerKey
246
+ });
247
+ this.core.emit('dataChanged', { source: 'answerKey' });
248
+ this.core.autoSave();
249
+ this.renderConnections();
250
+ this.updateGuideBar();
251
+ }
252
+ }
253
+
254
+ getAnswer(draggableId) {
255
+ return this.core.data.answerKey ? this.core.data.answerKey[draggableId] : null;
256
+ }
257
+
258
+ cleanupDanglingKeys() {
259
+ if (!this.core.data.answerKey) return;
260
+ const currentIds = new Set(this.core.data.objects.map(o => o.id));
261
+
262
+ let changed = false;
263
+ for (const [dragId, targetId] of Object.entries(this.core.data.answerKey)) {
264
+ if (!currentIds.has(dragId)) {
265
+ delete this.core.data.answerKey[dragId];
266
+ changed = true;
267
+ }
268
+ // Sıralama etkinliğinde targetId bazen indeks olabilir, nesne id'si ise kontrol et
269
+ if (this.core.data.project.activityType !== 'sequencing') {
270
+ if (!currentIds.has(targetId)) {
271
+ delete this.core.data.answerKey[dragId];
272
+ changed = true;
273
+ }
274
+ }
275
+ }
276
+ if (changed) {
277
+ this.core.emit('answerKeyChanged', { answerKey: this.core.data.answerKey });
278
+ this.core.autoSave();
279
+ }
280
+ }
281
+
282
+ // Görsel vurguları güncelle (Sadece seçilen kaynak nesne vurgulanır, diğer nesneler değişmez)
283
+ updateVisualHighlights() {
284
+ this.stage.querySelectorAll('.stage-object').forEach(el => {
285
+ el.classList.remove('ak-source-active', 'ak-target-candidate');
286
+ });
287
+
288
+ if (this.activeSourceId) {
289
+ this.stage.classList.add('ak-selecting-target');
290
+ const activeEl = this.stage.querySelector(`[data-id="${this.activeSourceId}"]`);
291
+ if (activeEl) {
292
+ activeEl.classList.add('ak-source-active');
293
+ }
294
+ } else {
295
+ this.stage.classList.remove('ak-selecting-target');
296
+ }
297
+ }
298
+
299
+ // SVG Bağlantı Çizgilerini Çiz
300
+ renderConnections() {
301
+ if (!this.svgConnections) return;
302
+ this.clearSvg();
303
+
304
+ const answerKey = this.core.data.answerKey || {};
305
+ const objectsMap = new Map();
306
+ this.core.data.objects.forEach(o => objectsMap.set(o.id, o));
307
+
308
+ for (const [dragId, targetId] of Object.entries(answerKey)) {
309
+ const sourceObj = objectsMap.get(dragId);
310
+ const targetObj = objectsMap.get(targetId);
311
+
312
+ if (!sourceObj || !targetObj) continue;
313
+
314
+ this.drawConnectionLine(sourceObj, targetObj, dragId);
315
+ }
316
+ }
317
+
318
+ // İki nesne arasındaki en optimum karşılıklı kenar merkezlerini ve yönü hesaplar
319
+ getFacingAnchorPoints(source, target) {
320
+ const sLeft = source.x;
321
+ const sRight = source.x + source.width;
322
+ const sTop = source.y;
323
+ const sBottom = source.y + source.height;
324
+ const sCx = source.x + source.width / 2;
325
+ const sCy = source.y + source.height / 2;
326
+
327
+ const tLeft = target.x;
328
+ const tRight = target.x + target.width;
329
+ const tTop = target.y;
330
+ const tBottom = target.y + target.height;
331
+ const tCx = target.x + target.width / 2;
332
+ const tCy = target.y + target.height / 2;
333
+
334
+ const dx = tCx - sCx;
335
+ const dy = tCy - sCy;
336
+
337
+ // 1. Yatay karşılıklı kenar adayı (Sağ -> Sol veya Sol -> Sağ)
338
+ let hPair;
339
+ if (dx >= 0) {
340
+ // Hedef sağda: Kaynak sağ kenarından hedefin sol kenarına
341
+ hPair = {
342
+ p1: { x: sRight, y: sCy },
343
+ p2: { x: tLeft, y: tCy },
344
+ dir: 'horizontal-right',
345
+ dist: Math.hypot(tLeft - sRight, tCy - sCy)
346
+ };
347
+ } else {
348
+ // Hedef solda: Kaynak sol kenarından hedefin sağ kenarına
349
+ hPair = {
350
+ p1: { x: sLeft, y: sCy },
351
+ p2: { x: tRight, y: tCy },
352
+ dir: 'horizontal-left',
353
+ dist: Math.hypot(sLeft - tRight, tCy - sCy)
354
+ };
355
+ }
356
+
357
+ // 2. Dikey karşılıklı kenar adayı (Alt -> Üst veya Üst -> Alt)
358
+ let vPair;
359
+ if (dy >= 0) {
360
+ // Hedef altta: Kaynak alt kenarından hedefin üst kenarına
361
+ vPair = {
362
+ p1: { x: sCx, y: sBottom },
363
+ p2: { x: tCx, y: tTop },
364
+ dir: 'vertical-down',
365
+ dist: Math.hypot(tCx - sCx, tTop - sBottom)
366
+ };
367
+ } else {
368
+ // Hedef üstte: Kaynak üst kenarından hedefin alt kenarına
369
+ vPair = {
370
+ p1: { x: sCx, y: sTop },
371
+ p2: { x: tCx, y: tBottom },
372
+ dir: 'vertical-up',
373
+ dist: Math.hypot(tCx - sCx, sTop - tBottom)
374
+ };
375
+ }
376
+
377
+ // Açık yön tayini:
378
+ // Sütunlar şeklinde yan yana dizilimlerde (kaynak sağ kenarı <= hedef sol kenarı) yatay bağlantı esastır
379
+ const isClearlyHorizontal = (sRight <= tLeft || tRight <= sLeft) && Math.abs(dx) >= Math.abs(dy);
380
+ const isClearlyVertical = (sBottom <= tTop || tBottom <= sTop) && Math.abs(dy) >= Math.abs(dx);
381
+
382
+ let chosen;
383
+ if (isClearlyHorizontal && !isClearlyVertical) {
384
+ chosen = hPair;
385
+ } else if (isClearlyVertical && !isClearlyHorizontal) {
386
+ chosen = vPair;
387
+ } else {
388
+ // Kenarlar arası gerçek Öklid mesafesi en kısa olan optimum karşılıklı kenar çiftini seç
389
+ chosen = (hPair.dist <= vPair.dist) ? hPair : vPair;
390
+ }
391
+
392
+ return { p1: chosen.p1, p2: chosen.p2, dir: chosen.dir };
393
+ }
394
+
395
+ drawConnectionLine(source, target, dragId) {
396
+ // Nesnelerin ortasından değil karşılıklı kenarlarından bağla
397
+ const { p1, p2, dir } = this.getFacingAnchorPoints(source, target);
398
+
399
+ const deltaX = Math.abs(p2.x - p1.x);
400
+ const deltaY = Math.abs(p2.y - p1.y);
401
+
402
+ let cx1, cy1, cx2, cy2;
403
+
404
+ if (dir === 'horizontal-right') {
405
+ const curvature = Math.max(30, Math.min(100, Math.max(deltaX * 0.5, 40)));
406
+ cx1 = p1.x + curvature;
407
+ cy1 = p1.y;
408
+ cx2 = p2.x - curvature;
409
+ cy2 = p2.y;
410
+ } else if (dir === 'horizontal-left') {
411
+ const curvature = Math.max(30, Math.min(100, Math.max(deltaX * 0.5, 40)));
412
+ cx1 = p1.x - curvature;
413
+ cy1 = p1.y;
414
+ cx2 = p2.x + curvature;
415
+ cy2 = p2.y;
416
+ } else if (dir === 'vertical-down') {
417
+ const curvature = Math.max(30, Math.min(100, Math.max(deltaY * 0.5, 40)));
418
+ cx1 = p1.x;
419
+ cy1 = p1.y + curvature;
420
+ cx2 = p2.x;
421
+ cy2 = p2.y - curvature;
422
+ } else { // 'vertical-up'
423
+ const curvature = Math.max(30, Math.min(100, Math.max(deltaY * 0.5, 40)));
424
+ cx1 = p1.x;
425
+ cy1 = p1.y - curvature;
426
+ cx2 = p2.x;
427
+ cy2 = p2.y + curvature;
428
+ }
429
+
430
+ const midX = (p1.x + p2.x) / 2;
431
+ const midY = (p1.y + p2.y) / 2;
432
+
433
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
434
+ g.setAttribute('class', 'ak-connection-group');
435
+
436
+ // Arka plan kalın yol (tıklamayı kolaylaştırmak için)
437
+ const hitPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
438
+ hitPath.setAttribute('d', `M ${p1.x} ${p1.y} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${p2.x} ${p2.y}`);
439
+ hitPath.setAttribute('class', 'ak-connection-hitbox');
440
+
441
+ // Görsel renkli çizgi
442
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
443
+ path.setAttribute('d', `M ${p1.x} ${p1.y} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${p2.x} ${p2.y}`);
444
+ path.setAttribute('class', 'ak-connection-path');
445
+
446
+ // Başlangıç ve bitiş noktası daireleri
447
+ const startCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
448
+ startCircle.setAttribute('cx', p1.x);
449
+ startCircle.setAttribute('cy', p1.y);
450
+ startCircle.setAttribute('r', '5');
451
+ startCircle.setAttribute('class', 'ak-node-circle start');
452
+
453
+ const endCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
454
+ endCircle.setAttribute('cx', p2.x);
455
+ endCircle.setAttribute('cy', p2.y);
456
+ endCircle.setAttribute('r', '6');
457
+ endCircle.setAttribute('class', 'ak-node-circle end');
458
+
459
+ // Orta rozet / Silme butonu (foreignObject ile HTML butonu - tam merkezlenmiş)
460
+ const fo = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
461
+ fo.setAttribute('x', midX - 13);
462
+ fo.setAttribute('y', midY - 13);
463
+ fo.setAttribute('width', '26');
464
+ fo.setAttribute('height', '26');
465
+
466
+ const btn = document.createElement('button');
467
+ btn.className = 'ak-unlink-btn';
468
+ btn.innerHTML = '✕';
469
+ btn.title = 'Eşleştirmeyi Kaldır';
470
+ btn.onclick = (e) => {
471
+ e.stopPropagation();
472
+ this.removeAnswer(dragId);
473
+ };
474
+ fo.appendChild(btn);
475
+
476
+ g.appendChild(hitPath);
477
+ g.appendChild(path);
478
+ g.appendChild(startCircle);
479
+ g.appendChild(endCircle);
480
+ g.appendChild(fo);
481
+
482
+ this.svgConnections.appendChild(g);
483
+ }
484
+
485
+ clearSvg() {
486
+ if (this.svgConnections) {
487
+ this.svgConnections.innerHTML = '';
488
+ }
489
+ }
490
+ }
491
+
492
+ // Global namespace
493
+ window.DND = window.DND || {};
494
+ window.DND.AnswerKeyManager = AnswerKeyManager;