diff --git a/css/word-reorder.css b/css/word-reorder.css index 48c1831..4eb1717 100644 --- a/css/word-reorder.css +++ b/css/word-reorder.css @@ -432,6 +432,248 @@ border-width: 3px; } } + +/* ===== ドラッグ&ドロップ スタイル ===== */ + +/* ドラッグ中の単語スタイル */ +.word-token.dragging { + opacity: 0.7; + transform: rotate(5deg) scale(1.05); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); + z-index: 1000; + pointer-events: none; + transition: none; /* ドラッグ中はトランジションを無効化 */ +} + +/* ドロップゾーンのハイライト */ +.drop-zone-highlight { + border-color: #4299e1 !important; + background-color: rgba(66, 153, 225, 0.05); + box-shadow: inset 0 0 0 2px rgba(66, 153, 225, 0.3); +} + +.drag-over { + border-color: #4299e1 !important; + background-color: rgba(66, 153, 225, 0.1) !important; + box-shadow: inset 0 0 0 3px #4299e1; + transform: scale(1.02); +} + +/* 挿入位置インジケーター */ +.insertion-indicator { + width: 3px; + height: 40px; + background: linear-gradient(to bottom, #4299e1, #63b3ed); + border-radius: 2px; + margin: 0 2px; + animation: insertionPulse 1s ease-in-out infinite; + box-shadow: 0 0 8px rgba(66, 153, 225, 0.6); +} + +.insertion-highlight { + background-color: rgba(66, 153, 225, 0.1) !important; + border-color: #4299e1 !important; + border-style: dashed !important; + animation: highlightPulse 1.5s ease-in-out infinite; +} + +/* ドロップ成功アニメーション */ +.word-token.drop-success { + animation: dropSuccess 0.3s ease-out; +} + +/* ドロップ失敗アニメーション */ +.word-token.drop-failed { + animation: dropFailed 0.4s ease-out; +} + +/* ドラッグプレビュー(タッチデバイス用) */ +.drag-preview { + background-color: rgba(255, 255, 255, 0.95); + border: 2px solid #4299e1; + border-radius: 8px; + padding: 0.4rem 0.8rem; + font-family: 'Fira Code', 'Consolas', monospace; + font-size: 0.9rem; + font-weight: 500; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); + backdrop-filter: blur(4px); +} + +/* アニメーション定義 */ +@keyframes insertionPulse { + 0%, 100% { + opacity: 0.6; + transform: scaleY(0.8); + } + 50% { + opacity: 1; + transform: scaleY(1.2); + } +} + +@keyframes highlightPulse { + 0%, 100% { + background-color: rgba(66, 153, 225, 0.05); + } + 50% { + background-color: rgba(66, 153, 225, 0.15); + } +} + +@keyframes dropSuccess { + 0% { + transform: scale(1.2); + background-color: rgba(72, 187, 120, 0.3); + } + 50% { + transform: scale(1.1); + background-color: rgba(72, 187, 120, 0.2); + } + 100% { + transform: scale(1); + background-color: transparent; + } +} + +@keyframes dropFailed { + 0%, 100% { + transform: translateX(0); + } + 25% { + transform: translateX(-10px); + background-color: rgba(245, 101, 101, 0.2); + } + 75% { + transform: translateX(10px); + background-color: rgba(245, 101, 101, 0.2); + } +} + +/* カーソルスタイル */ +.word-token { + cursor: grab; +} + +.word-token:active { + cursor: grabbing; +} + +.word-token.dragging { + cursor: grabbing; +} + +/* ドロップ不可能な状態 */ +.no-drop { + cursor: no-drop !important; +} + +/* レスポンシブ対応 - ドラッグ&ドロップ */ +@media (max-width: 768px) { + .insertion-indicator { + height: 35px; + width: 4px; + } + + .drag-preview { + font-size: 0.85rem; + padding: 0.3rem 0.6rem; + } + + .word-token.dragging { + transform: rotate(3deg) scale(1.03); + } +} + +@media (max-width: 480px) { + .insertion-indicator { + height: 30px; + width: 4px; + } + + .drag-preview { + font-size: 0.8rem; + padding: 0.25rem 0.5rem; + } + + .word-token.dragging { + transform: rotate(2deg) scale(1.02); + } +} + +/* タッチデバイス最適化 */ +@media (hover: none) and (pointer: coarse) { + .word-token { + cursor: default; + } + + .word-token:active { + cursor: default; + } + + /* タッチデバイスでのドラッグフィードバック強化 */ + .word-token.dragging { + opacity: 0.8; + transform: rotate(3deg) scale(1.1); + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.4); + } +} + +/* アクセシビリティ - ドラッグ&ドロップ */ +@media (prefers-reduced-motion: reduce) { + .word-token.dragging { + transform: none; + animation: none; + } + + .insertion-indicator { + animation: none; + } + + .insertion-highlight { + animation: none; + } + + .word-token.drop-success, + .word-token.drop-failed { + animation: none; + } + + .drag-over { + transform: none; + } +} + +/* ハイコントラストモード - ドラッグ&ドロップ */ +@media (prefers-contrast: high) { + .word-token.dragging { + border-width: 4px; + border-color: #000; + } + + .drag-over { + border-width: 4px; + border-color: #0066cc; + } + + .insertion-indicator { + background: #0066cc; + width: 5px; + } +} + +/* スクリーンリーダー専用(視覚的に隠す) */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} /* ゲームオーバーレイ(アニメーション用) */ .game-overlay { diff --git a/js/word-reorder-ui.js b/js/word-reorder-ui.js index 708b182..594e4d1 100644 --- a/js/word-reorder-ui.js +++ b/js/word-reorder-ui.js @@ -38,7 +38,7 @@ export class WordReorderUI { this.container.innerHTML = `
-

単語をタップして正しい順序に並び替えてください

+

単語をタップまたはドラッグして正しい順序に並び替えてください

@@ -62,6 +62,9 @@ export class WordReorderUI { ✓ 回答確認
+ + +
`; @@ -87,15 +90,37 @@ export class WordReorderUI { * ドラッグ&ドロップ機能を初期化 */ initializeDragAndDrop() { - // ドロップゾーンの設定 - this.setupDropZones(); - - // ドラッグプレビュー要素を作成 - this.createDragPreview(); + // ブラウザ互換性チェック + this.dragDropSupported = this.checkDragDropSupport(); - // グローバルドラッグイベントの設定 - document.addEventListener('dragover', (e) => e.preventDefault()); - document.addEventListener('drop', (e) => e.preventDefault()); + if (this.dragDropSupported) { + // ドロップゾーンの設定 + this.setupDropZones(); + + // ドラッグプレビュー要素を作成 + this.createDragPreview(); + + // グローバルドラッグイベントの設定 + document.addEventListener('dragover', (e) => e.preventDefault()); + document.addEventListener('drop', (e) => e.preventDefault()); + } else { + console.info('ドラッグ&ドロップがサポートされていません。タップ操作のみ利用可能です。'); + } + } + + /** + * ドラッグ&ドロップサポートをチェック + * @returns {boolean} サポートされているかどうか + */ + checkDragDropSupport() { + try { + // HTML5 Drag and Drop API のサポートチェック + const div = document.createElement('div'); + return ('draggable' in div) && ('ondragstart' in div) && ('ondrop' in div); + } catch (error) { + console.warn('ドラッグ&ドロップサポートチェックでエラー:', error); + return false; + } } /** @@ -217,6 +242,13 @@ export class WordReorderUI { wordElement.dataset.index = index; wordElement.dataset.tokenType = token.type; + // アクセシビリティ属性を設定 + wordElement.setAttribute('role', 'button'); + wordElement.setAttribute('tabindex', '0'); + wordElement.setAttribute('aria-label', + `${token.text} - ${type === 'available' ? '選択可能な単語' : '選択済みの単語'}`); + wordElement.setAttribute('aria-grabbed', 'false'); + // ドラッグ&ドロップ機能を追加 this.makeDraggable(wordElement, token, type, index); @@ -226,12 +258,17 @@ export class WordReorderUI { this.onWordTap(type, index); }); - // タッチイベントの設定(モバイル対応) - wordElement.addEventListener('touchend', (e) => { - e.preventDefault(); - this.onWordTap(type, index); + // キーボードナビゲーション対応 + wordElement.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.onWordTap(type, index); + } }); + // タッチイベントの設定(モバイル対応) + // touchendイベントはmakeDraggableメソッド内で処理されるため、ここでは設定しない + return wordElement; } @@ -243,15 +280,25 @@ export class WordReorderUI { * @param {number} index - 配列内のインデックス */ makeDraggable(element, token, type, index) { - // HTML5 Drag and Drop - element.draggable = true; - element.addEventListener('dragstart', (e) => this.onDragStart(e, token, type, index)); - element.addEventListener('dragend', (e) => this.onDragEnd(e)); + if (!this.dragDropSupported) { + // ドラッグ&ドロップがサポートされていない場合はスキップ + return; + } - // Touch Events for mobile - element.addEventListener('touchstart', (e) => this.onTouchStart(e, token, type, index)); - element.addEventListener('touchmove', (e) => this.onTouchMove(e)); - element.addEventListener('touchend', (e) => this.onTouchEnd(e)); + try { + // HTML5 Drag and Drop + element.draggable = true; + element.addEventListener('dragstart', (e) => this.onDragStart(e, token, type, index)); + element.addEventListener('dragend', (e) => this.onDragEnd(e)); + + // Touch Events for mobile + element.addEventListener('touchstart', (e) => this.onTouchStart(e, token, type, index)); + element.addEventListener('touchmove', (e) => this.onTouchMove(e)); + element.addEventListener('touchend', (e) => this.onTouchEnd(e)); + } catch (error) { + console.warn('ドラッグ機能の設定でエラーが発生しました:', error); + // エラーが発生してもタップ機能は維持される + } } /** @@ -342,10 +389,33 @@ export class WordReorderUI { this.dragState.draggedFromIndex = index; this.dragState.draggedElement = event.target; - // ドラッグデータを設定 - event.dataTransfer.setData('text/plain', JSON.stringify({ - token, type, index - })); + // ドラッグデータを設定(複数の形式で設定) + const dragData = { + token, + type, + index, + timestamp: Date.now(), + sourceId: `word-reorder-${type}-${index}` + }; + + event.dataTransfer.setData('text/plain', JSON.stringify(dragData)); + event.dataTransfer.setData('application/json', JSON.stringify(dragData)); + + // ドラッグ効果を設定 + event.dataTransfer.effectAllowed = 'move'; + + // カスタムドラッグイメージを設定(オプション) + try { + const dragImage = event.target.cloneNode(true); + dragImage.style.opacity = '0.8'; + event.dataTransfer.setDragImage(dragImage, + event.target.offsetWidth / 2, + event.target.offsetHeight / 2 + ); + } catch (e) { + // ドラッグイメージ設定に失敗した場合は無視 + console.debug('カスタムドラッグイメージの設定に失敗:', e); + } // 視覚的フィードバック event.target.classList.add('dragging'); @@ -635,12 +705,44 @@ export class WordReorderUI { * @returns {number} 挿入位置 */ calculateInsertPosition(event, targetType) { - // 簡単な実装:末尾に挿入 - if (targetType === 'selected') { - return this.selectedWords.length; - } else { - return this.availableWords.length; + const container = targetType === 'selected' ? + this.selectedWordsContainer : this.availableWordsContainer; + const words = targetType === 'selected' ? + this.selectedWords : this.availableWords; + + // コンテナ内の単語要素を取得 + const wordElements = Array.from(container.querySelectorAll('.word-token')); + + if (wordElements.length === 0) { + return 0; // 空の場合は最初の位置 } + + // マウス/タッチ位置を取得 + const clientX = event.clientX || (event.touches && event.touches[0]?.clientX) || 0; + const clientY = event.clientY || (event.touches && event.touches[0]?.clientY) || 0; + + // 各単語要素との距離を計算 + let closestIndex = words.length; // デフォルトは末尾 + let minDistance = Infinity; + + wordElements.forEach((element, index) => { + const rect = element.getBoundingClientRect(); + const elementCenterX = rect.left + rect.width / 2; + const elementCenterY = rect.top + rect.height / 2; + + // 距離を計算(主にX軸を重視) + const deltaX = clientX - elementCenterX; + const deltaY = clientY - elementCenterY; + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY * 0.5); // Y軸の重みを軽減 + + if (distance < minDistance) { + minDistance = distance; + // 要素の左半分なら前に、右半分なら後に挿入 + closestIndex = deltaX < 0 ? index : index + 1; + } + }); + + return Math.max(0, Math.min(closestIndex, words.length)); } /** @@ -649,14 +751,47 @@ export class WordReorderUI { * @param {string} type - エリアタイプ */ showInsertionIndicator(position, type) { - // 後で実装予定 + // 既存のインジケーターを削除 + this.hideInsertionIndicator(); + + const container = type === 'selected' ? + this.selectedWordsContainer : this.availableWordsContainer; + const words = type === 'selected' ? + this.selectedWords : this.availableWords; + + if (words.length === 0) { + // 空のコンテナの場合は全体をハイライト + container.classList.add('insertion-highlight'); + return; + } + + // 挿入位置インジケーターを作成 + const indicator = document.createElement('div'); + indicator.className = 'insertion-indicator'; + indicator.dataset.insertionIndicator = 'true'; + + const wordElements = Array.from(container.querySelectorAll('.word-token')); + + if (position >= wordElements.length) { + // 末尾に挿入 + container.appendChild(indicator); + } else { + // 指定位置に挿入 + container.insertBefore(indicator, wordElements[position]); + } } /** * 挿入位置インジケーターを非表示 */ hideInsertionIndicator() { - // 後で実装予定 + // インジケーター要素を削除 + const indicators = document.querySelectorAll('[data-insertion-indicator="true"]'); + indicators.forEach(indicator => indicator.remove()); + + // ハイライトクラスを削除 + this.selectedWordsContainer.classList.remove('insertion-highlight'); + this.availableWordsContainer.classList.remove('insertion-highlight'); } /** @@ -668,8 +803,22 @@ export class WordReorderUI { performDrop(dragData, targetType, insertPosition) { const { token, type: sourceType, index: sourceIndex } = dragData; - // 同じ場所へのドロップは無視 + // 同じエリア内での並び替えの場合 if (sourceType === targetType) { + if (targetType === 'selected') { + // 回答エリア内での並び替え + const movedToken = this.selectedWords.splice(sourceIndex, 1)[0]; + + // 挿入位置を調整(削除により位置がずれる場合) + let adjustedPosition = insertPosition; + if (sourceIndex < insertPosition) { + adjustedPosition--; + } + + this.selectedWords.splice(adjustedPosition, 0, movedToken); + this.renderSelectedWords(); + } + // 選択可能エリア内での並び替えは通常不要だが、将来の拡張のために残す return; } @@ -680,16 +829,36 @@ export class WordReorderUI { this.selectedWords.splice(sourceIndex, 1); } - // ターゲットに追加 + // ターゲットに挿入位置を考慮して追加 if (targetType === 'available') { + // 選択可能エリアは通常末尾に追加 this.availableWords.push(token); } else { - this.selectedWords.push(token); + // 回答エリアは指定位置に挿入 + const safePosition = Math.max(0, Math.min(insertPosition, this.selectedWords.length)); + this.selectedWords.splice(safePosition, 0, token); } // UIを更新 this.renderAvailableWords(); this.renderSelectedWords(); + + // ドロップ成功のアニメーション効果 + setTimeout(() => { + const targetContainer = targetType === 'selected' ? + this.selectedWordsContainer : this.availableWordsContainer; + const wordElements = targetContainer.querySelectorAll('.word-token'); + const targetElement = Array.from(wordElements).find(el => + el.textContent === token.text + ); + + if (targetElement) { + targetElement.classList.add('drop-success'); + setTimeout(() => { + targetElement.classList.remove('drop-success'); + }, 300); + } + }, 10); } /** @@ -703,6 +872,15 @@ export class WordReorderUI { this.dragState.isDragging = true; this.dragState.draggedElement = element; + // 触覚フィードバック(サポートされている場合) + try { + if (navigator.vibrate) { + navigator.vibrate(50); // 50ms の短い振動 + } + } catch (error) { + // 振動APIがサポートされていない場合は無視 + } + // 視覚的フィードバック element.classList.add('dragging'); this.highlightDropZones(); diff --git a/slides/challenges.json b/slides/challenges.json index 6582385..87fd9e5 100644 --- a/slides/challenges.json +++ b/slides/challenges.json @@ -5,7 +5,7 @@ "content": "# SELECT文の基本\n\nSELECT文はデータベースからデータを取得するための**基本的なSQL文**です。\n\n## 基本構文\n\n```sql\nSELECT カラム名 FROM テーブル名;\n```\n\n## 主な使い方\n\n- **全てのカラムを取得**: `SELECT *`\n- **特定のカラムを取得**: `SELECT カラム1, カラム2`\n- **別名を付ける**: `SELECT カラム名 AS 別名`\n\n### 実例\n\n```sql\nSELECT product_name, price \nFROM products;\n```\n\n*商品名と価格を取得する例*" }, { - "type": "word-reorder", + "type": "challenge", "id": "challenge-001", "title": "商品一覧を表示しよう", "description": "単語を正しい順序に並び替えて、productsテーブルから全ての商品情報を取得するSQLを完成させてください。",