diff --git a/.gitignore b/.gitignore index 14735c6..8f96c71 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ node_modules .discourse-site + +# Local History extension backups +.history/ diff --git a/common/common.scss b/common/common.scss index 706c6fb..52c5196 100644 --- a/common/common.scss +++ b/common/common.scss @@ -82,6 +82,21 @@ html.kanban-active { display: flex; gap: 0 10px; + // Add invisible scroll space using pseudo-elements + &::before { + content: ""; + min-width: 100vw; + height: 1px; + flex-shrink: 0; + } + + &::after { + content: ""; + min-width: 100vw; + height: 1px; + flex-shrink: 0; + } + .discourse-kanban-list { background: var(--primary-100); border: 1px solid var(--primary-low); @@ -113,6 +128,7 @@ html.kanban-active { overflow-y: scroll; padding: 0 8px; height: 100%; + padding-bottom: 100vh; // Allow scrolling down to position cards at bottom } .topic-card { @@ -124,8 +140,14 @@ html.kanban-active { box-shadow: var(--primary-low) 0 3px 6px; border: 1px solid var(--primary-low); + // Prevent iOS/Android long-press text selection and callout menu + user-select: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; + &.dragging { - background-color: var(--tertiary-low); + opacity: 0.8 !important; + box-shadow: 0 5px 15px rgb(0 0 0 / 0.3) !important; } &.card-no-recent-activity { @@ -264,3 +286,46 @@ ul.kanban-controls { } } } + +// Mobile touch drag styles +.kanban-dragging-clone { + opacity: 0.8; + box-shadow: 0 0 15px rgb(var(--primary-rgb) / 0.3); + + // Preserve card padding when moved outside normal container + padding: 10px !important; +} + +.kanban-drop-button { + padding: 6px; + font-size: 14px; + font-weight: bold; + color: var(--secondary); + background-color: var(--success); + border: none; + border-radius: 4px; + box-shadow: 0 2px 8px rgb(var(--primary-rgb) / 0.5); + cursor: pointer; + + &:hover { + background-color: var(--success-hover); + } +} + +.kanban-cancel-button { + padding: 0; + font-size: 18px; + font-weight: bold; + line-height: 26px; + text-align: center; + color: var(--secondary); + background-color: var(--danger); + border: none; + border-radius: 50%; + box-shadow: 0 2px 8px rgb(var(--primary-rgb) / 0.5); + cursor: pointer; + + &:hover { + background-color: var(--danger-hover); + } +} diff --git a/javascripts/discourse/components/kanban/card.gjs b/javascripts/discourse/components/kanban/card.gjs index 003fea6..a6f3cbc 100644 --- a/javascripts/discourse/components/kanban/card.gjs +++ b/javascripts/discourse/components/kanban/card.gjs @@ -4,6 +4,7 @@ import { on } from "@ember/modifier"; import { action } from "@ember/object"; import { service } from "@ember/service"; import { htmlSafe } from "@ember/template"; +import { modifier } from "ember-modifier"; import PluginOutlet from "discourse/components/plugin-outlet"; import TopicStatus from "discourse/components/topic-status"; import categoryBadge from "discourse/helpers/category-badge"; @@ -14,16 +15,410 @@ import lazyHash from "discourse/helpers/lazy-hash"; import { renderAvatar } from "discourse/helpers/user-avatar"; import renderTag from "discourse/lib/render-tag"; +const touchDrag = modifier((element, [component]) => { + let longPressTimer = null; + let isDragging = false; + let startX = 0; + let startY = 0; + let clone = null; + let cloneX = 0; + let cloneY = 0; + let dropButton = null; + let cancelButton = null; + let animationFrameId = null; + let scrollContainer = null; + let clickBlocker = null; + + const handleTouchStart = (e) => { + // Don't allow drag if user is not logged in + if (!component.currentUser) { + return; + } + + // Don't allow picking up another card if ANY card is being dragged + const existingClone = document.querySelector(".kanban-dragging-clone"); + if (existingClone) { + e.preventDefault(); + return; + } + + const touch = e.touches[0]; + startX = touch.clientX; + startY = touch.clientY; + + // Only start long press if not already dragging + if (!isDragging) { + longPressTimer = setTimeout(() => { + pickUpCard(touch); + }, 500); + } + }; + + const pickUpCard = () => { + isDragging = true; + + // Create visual clone at fixed position + clone = element.cloneNode(true); + clone.classList.add("kanban-dragging-clone"); + clone.style.position = "fixed"; + clone.style.zIndex = "10000"; + clone.style.pointerEvents = "none"; + clone.style.width = element.offsetWidth + "px"; + + // Get the original card's position in viewport + const rect = element.getBoundingClientRect(); + + // Position clone directly above the original card (same horizontal position) + cloneX = rect.left; + cloneY = rect.top - clone.offsetHeight - 44; // Move up by clone height + space for buttons + clone.style.left = cloneX + "px"; + clone.style.top = cloneY + "px"; + document.body.appendChild(clone); + + // Create drop button underneath the clone + dropButton = document.createElement("button"); + dropButton.textContent = "Drop Card"; + dropButton.classList.add("kanban-drop-button"); + dropButton.style.position = "fixed"; + dropButton.style.left = cloneX + "px"; + dropButton.style.top = cloneY + clone.offsetHeight + 4 + "px"; + dropButton.style.width = element.offsetWidth + "px"; + dropButton.style.zIndex = "10001"; + dropButton.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + dropCard(); + }; + document.body.appendChild(dropButton); + + // Create cancel button (X) at top right of clone + cancelButton = document.createElement("button"); + cancelButton.textContent = "✕"; + cancelButton.classList.add("kanban-cancel-button"); + cancelButton.style.position = "fixed"; + cancelButton.style.left = cloneX + element.offsetWidth - 30 + "px"; + cancelButton.style.top = cloneY + 4 + "px"; + cancelButton.style.width = "26px"; + cancelButton.style.height = "26px"; + cancelButton.style.zIndex = "10002"; + cancelButton.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + cancelDrag(); + }; + document.body.appendChild(cancelButton); + + // Create full-screen overlay that blocks clicks but not scrolling + clickBlocker = document.createElement("div"); + clickBlocker.style.position = "fixed"; + clickBlocker.style.top = "0"; + clickBlocker.style.left = "0"; + clickBlocker.style.width = "100vw"; + clickBlocker.style.height = "100vh"; + clickBlocker.style.zIndex = "9999"; // Below clone but above everything else + clickBlocker.style.pointerEvents = "none"; // Allow scrolling + clickBlocker.style.cursor = "grabbing"; + document.body.appendChild(clickBlocker); + + // Block clicks on all cards + const allCards = document.querySelectorAll(".topic-card"); + allCards.forEach((card) => { + card.style.pointerEvents = "none"; + card.dataset.dragBlocked = "true"; + }); + + // Find the scrolling container + scrollContainer = element.closest(".discourse-kanban"); + + // Start animation loop for position updates + const trackPosition = () => { + if (isDragging) { + updateClonePosition(); + animationFrameId = requestAnimationFrame(trackPosition); + } + }; + animationFrameId = requestAnimationFrame(trackPosition); + + // Dim original card ONLY for mobile (when there's a clone floating above) + // Desktop drag uses CSS .dragging class instead + element.style.opacity = "0.3"; + + // Trigger the native dragstart event + const dragStartEvent = new DragEvent("dragstart", { + bubbles: true, + cancelable: true, + dataTransfer: new DataTransfer(), + }); + element.dispatchEvent(dragStartEvent); + + if (navigator.vibrate) { + navigator.vibrate(50); + } + }; + + const cancelDrag = () => { + if (!isDragging) { + return; + } + + // Cancel animation frame + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + + // Remove clone, buttons, and click blocker + if (clone) { + clone.remove(); + clone = null; + } + if (dropButton) { + dropButton.remove(); + dropButton = null; + } + if (cancelButton) { + cancelButton.remove(); + cancelButton = null; + } + if (clickBlocker) { + clickBlocker.remove(); + clickBlocker = null; + } + + // Restore pointer events on all cards + const blockedCards = document.querySelectorAll( + '[data-drag-blocked="true"]' + ); + blockedCards.forEach((card) => { + card.style.pointerEvents = ""; + delete card.dataset.dragBlocked; + }); + + // Restore original card + element.style.opacity = ""; + + // Trigger dragend event without dropping + const dragEndEvent = new DragEvent("dragend", { + bubbles: true, + cancelable: true, + }); + element.dispatchEvent(dragEndEvent); + + isDragging = false; + if (navigator.vibrate) { + navigator.vibrate(20); + } + }; + + const dropCard = () => { + if (!isDragging) { + return; + } + + // Cancel animation frame + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + + // Find what's under the clone's center position + const centerX = cloneX + clone.offsetWidth / 2; + const centerY = cloneY + clone.offsetHeight / 2; + const targetElement = document.elementFromPoint(centerX, centerY); + const listElement = targetElement?.closest(".discourse-kanban-list"); + + // Remove clone, buttons, and click blocker + if (clone) { + clone.remove(); + clone = null; + } + if (dropButton) { + dropButton.remove(); + dropButton = null; + } + if (cancelButton) { + cancelButton.remove(); + cancelButton = null; + } + if (clickBlocker) { + clickBlocker.remove(); + clickBlocker = null; + } + + // Restore pointer events on all cards + const blockedCards = document.querySelectorAll( + '[data-drag-blocked="true"]' + ); + blockedCards.forEach((card) => { + card.style.pointerEvents = ""; + delete card.dataset.dragBlocked; + }); + + // Restore original card + element.style.opacity = ""; + + if (listElement) { + const dropEvent = new DragEvent("drop", { + bubbles: true, + cancelable: true, + }); + listElement.dispatchEvent(dropEvent); + } + + // Trigger dragend event + const dragEndEvent = new DragEvent("dragend", { + bubbles: true, + cancelable: true, + }); + element.dispatchEvent(dragEndEvent); + + isDragging = false; + if (navigator.vibrate) { + navigator.vibrate(30); + } + }; + + const handleTouchMove = (e) => { + const touch = e.touches[0]; + const deltaX = Math.abs(touch.clientX - startX); + const deltaY = Math.abs(touch.clientY - startY); + + // Cancel long press if finger moves too much before timer fires + if (longPressTimer && (deltaX > 10 || deltaY > 10)) { + clearTimeout(longPressTimer); + longPressTimer = null; + } + + // Allow free scrolling whether dragging or not + }; + + const updateClonePosition = () => { + if (!clone || !scrollContainer) { + return; + } + + // Get the Kanban container boundaries in viewport + const containerRect = scrollContainer.getBoundingClientRect(); + const cloneWidth = clone.offsetWidth; + const cloneHeight = clone.offsetHeight; + + // Card stays locked to its initial viewport position + // But constrain it to stay within the visible Kanban container + let finalX = cloneX; + let finalY = cloneY; + + // Keep card within horizontal bounds of container + if (finalX < containerRect.left) { + finalX = containerRect.left; + } else if (finalX + cloneWidth > containerRect.right) { + finalX = containerRect.right - cloneWidth; + } + + // Keep card within vertical bounds of container + if (finalY < containerRect.top) { + finalY = containerRect.top; + } else if (finalY + cloneHeight + 44 > containerRect.bottom) { + finalY = containerRect.bottom - cloneHeight - 44; + } + + // Update positions + clone.style.left = finalX + "px"; + clone.style.top = finalY + "px"; + + if (dropButton) { + dropButton.style.left = finalX + "px"; + dropButton.style.top = finalY + cloneHeight + 4 + "px"; + } + + if (cancelButton) { + cancelButton.style.left = finalX + cloneWidth - 30 + "px"; + cancelButton.style.top = finalY + 4 + "px"; + } + }; + + const handleTouchEnd = () => { + if (longPressTimer) { + clearTimeout(longPressTimer); + longPressTimer = null; + } + + // Don't drop on touch end - wait for drop button click + }; + + const handleContextMenu = (event) => { + // Prevent context menu always during touch interaction + event.preventDefault(); + }; + + element.addEventListener("touchstart", handleTouchStart, { passive: false }); + element.addEventListener("touchmove", handleTouchMove, { passive: false }); + element.addEventListener("touchend", handleTouchEnd, { passive: false }); + element.addEventListener("contextmenu", handleContextMenu); + + // Scroll to show first column on initial load + const container = element.closest(".discourse-kanban"); + if (container && container.scrollLeft === 0) { + // Check if we're at the very start (in the invisible spacer) + const firstList = container.querySelector( + ".discourse-kanban-list:first-child" + ); + if (firstList) { + setTimeout(() => { + firstList.scrollIntoView({ + inline: "start", + block: "nearest", + behavior: "auto", + }); + }, 100); + } + } + + return () => { + if (longPressTimer) { + clearTimeout(longPressTimer); + } + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + // Cancel any active drag on cleanup (e.g., navigation) + if (isDragging) { + cancelDrag(); + } + element.removeEventListener("touchstart", handleTouchStart); + element.removeEventListener("touchmove", handleTouchMove); + element.removeEventListener("touchend", handleTouchEnd); + element.removeEventListener("contextmenu", handleContextMenu); + }; +}); + export default class KanbanCard extends Component { @service kanbanManager; + @service currentUser; @tracked dragging; @action dragStart(event) { - this.dragging = true; + // Don't allow drag if user is not logged in + if (!this.currentUser) { + event.preventDefault(); + return; + } + + // Always set drag data (needed for both mobile and desktop) this.args.setDragData({ topic: this.args.topic }); - event.dataTransfer.dropEffect = "move"; + + // Don't set dragging state if mobile touch drag is active + // (Mobile handles its own visual styling via the clone) + const existingClone = document.querySelector(".kanban-dragging-clone"); + if (existingClone) { + return; + } + + this.dragging = true; + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "move"; + } event.stopPropagation(); } @@ -104,6 +499,7 @@ export default class KanbanCard extends Component { data-topic-id={{@topic.id}} {{on "dragstart" this.dragStart}} {{on "dragend" this.dragEnd}} + {{touchDrag this}} >