import { computed, ref, watch } from 'vue' import type { Well, WellEditData, WellPlateSelectionMode, WellSampleDropData, WellSampleDropParser, } from '../types' import { getRectangleWellIds, type WellPlateCoordinate, } from './WellPlate.geometry' import { parseDroppedSampleTransfer } from './WellPlate.sampleDrop' import { useReorderAnnouncer } from '../composables/useListReorder' export interface WellPlateInteractionOptions { modelValue: () => string[] selectionMode: () => WellPlateSelectionMode disabled: () => boolean readonly: () => boolean editable: () => boolean allowSampleDrop: () => boolean sampleDropParser: () => WellSampleDropParser | undefined rowLabels: () => string[] wellGrid: () => Well[][] emitSelected: (wellIds: string[]) => void emitWellClick: (wellId: string, event: MouseEvent) => void emitWellHover: (wellId: string | null, event?: MouseEvent) => void emitContextMenu: (wellId: string, event: MouseEvent) => void emitWellMove: (sourceWellId: string, targetWellId: string) => void emitWellEdit: (wellId: string, data: WellEditData) => void emitWellClear: (wellId: string) => void emitSampleDrop: (wellId: string, data: WellSampleDropData, event: DragEvent) => void } export function nextClickedWellSelection( selectedWellIds: string[], wellId: string, selectionMode: WellPlateSelectionMode, event: Pick, ): string[] { const selectedWellSet = new Set(selectedWellIds) const isCurrentlySelected = selectedWellSet.has(wellId) const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey if (selectionMode === 'single') { return isCurrentlySelected ? [] : [wellId] } if (isMultiSelect) { return isCurrentlySelected ? selectedWellIds.filter(id => id !== wellId) : [...selectedWellIds, wellId] } return isCurrentlySelected && selectedWellIds.length === 1 ? [] : [wellId] } export function useWellPlateInteraction(options: WellPlateInteractionOptions) { const dragSourceWell = ref(null) const dragTargetWell = ref(null) const isDragging = ref(false) const dragStart = ref(null) const dragEnd = ref(null) const hoveredWell = ref(null) const editingWellId = ref(null) const editPopupPosition = ref({ x: 0, y: 0 }) // A popover hidden by `editable` turning off stays closed when it turns back on. watch(options.editable, editable => { if (!editable) editingWellId.value = null }) const { announcement, announce, announcerStyle } = useReorderAnnouncer() // Shift-click ranges from the last plain or Ctrl/Cmd-clicked well, or a dragged rectangle's // start; a cleared selection drops it. let anchorWellId: string | null = null let dragStartWellId: string | null = null watch(() => options.modelValue().length === 0, empty => { if (empty) anchorWellId = null }) const selectedWellSet = computed(() => new Set(options.modelValue())) const dragSelectedWells = computed(() => { if (!isDragging.value || !dragStart.value || !dragEnd.value) return new Set() return getRectangleWellIds(dragStart.value, dragEnd.value, options.rowLabels()) }) function emitSelected(wellIds: string[]) { options.emitSelected(wellIds) } function isSelected(wellId: string): boolean { return selectedWellSet.value.has(wellId) || dragSelectedWells.value.has(wellId) } function openEditPopup(wellId: string, event: MouseEvent) { const target = event.currentTarget as HTMLElement const rect = target.getBoundingClientRect() editPopupPosition.value = { x: rect.right + 8, y: rect.top, } editingWellId.value = wellId } function handleWellClick(well: Well, event: MouseEvent) { if (options.disabled()) return // Readonly forbids edits, not clicks: the caller may still react (e.g. open details). options.emitWellClick(well.id, event) if (options.readonly()) return const selectionMode = options.selectionMode() const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey const canMultiSelect = selectionMode === 'multiple' || selectionMode === 'rectangle' // On an editable plate a plain click opens the editor; a modifier click still // toggles the selection so single-well editing and bulk actions coexist. if (options.editable() && !(isMultiSelect && canMultiSelect)) { anchorWellId = well.id openEditPopup(well.id, event) return } if (selectionMode === 'none') return // Looked up by id: a format change leaves no stale coordinates behind. const anchor = event.shiftKey && canMultiSelect ? options.wellGrid().flat().find(w => w.id === anchorWellId) : undefined if (anchor) { const range = getRectangleWellIds(anchor, well, options.rowLabels()) // Ctrl/Cmd+Shift adds the range to the selection; Shift alone replaces it. const keep = event.ctrlKey || event.metaKey ? options.modelValue() : [] emitSelected(Array.from(new Set([...keep, ...range]))) return } anchorWellId = well.id emitSelected(nextClickedWellSelection(options.modelValue(), well.id, selectionMode, event)) } function handleEditSave(data: WellEditData) { options.emitWellEdit(data.wellId, data) editingWellId.value = null } function handleEditClear() { if (editingWellId.value) options.emitWellClear(editingWellId.value) editingWellId.value = null } function handleEditClose() { editingWellId.value = null } function handleWellMouseDown(well: Well, event: MouseEvent) { if (options.disabled() || options.readonly() || options.selectionMode() !== 'rectangle') return if (options.editable()) return if (event.button !== 0) return isDragging.value = true dragStartWellId = well.id dragStart.value = { row: well.row, col: well.col } dragEnd.value = { row: well.row, col: well.col } } function handleWellMouseEnter(well: Well, event: MouseEvent) { hoveredWell.value = well.id options.emitWellHover(well.id, event) if (isDragging.value && options.selectionMode() === 'rectangle') { dragEnd.value = { row: well.row, col: well.col } } } function handleWellMouseLeave() { hoveredWell.value = null options.emitWellHover(null) } function handleMouseUp() { if (!isDragging.value || options.selectionMode() !== 'rectangle') return const newSelection = Array.from(dragSelectedWells.value) isDragging.value = false dragStart.value = null dragEnd.value = null // A one-well rectangle is a click: the click handler selects (or toggles) it. if (newSelection.length > 1) { anchorWellId = dragStartWellId emitSelected(newSelection) } } function handleContextMenu(well: Well, event: MouseEvent) { event.preventDefault() options.emitContextMenu(well.id, event) } function handleDragStart(well: Well, event: DragEvent) { if (options.disabled() || options.readonly() || options.selectionMode() !== 'drag') return if (!well.sampleType) return dragSourceWell.value = well.id if (event.dataTransfer) { event.dataTransfer.effectAllowed = 'move' event.dataTransfer.setData('text/plain', well.id) } } function handleDragOver(well: Well, event: DragEvent) { const isMovingWell = options.selectionMode() === 'drag' && !!dragSourceWell.value if (!isMovingWell && !options.allowSampleDrop()) return event.preventDefault() if (event.dataTransfer) { event.dataTransfer.dropEffect = isMovingWell ? 'move' : 'copy' } dragTargetWell.value = well.id } function handleDragLeave() { dragTargetWell.value = null } function handleDrop(well: Well, event: DragEvent) { const isMovingWell = options.selectionMode() === 'drag' && !!dragSourceWell.value if (!isMovingWell && !options.allowSampleDrop()) return event.preventDefault() if (isMovingWell && dragSourceWell.value) { const sourceId = dragSourceWell.value const targetId = well.id if (sourceId !== targetId) options.emitWellMove(sourceId, targetId) dragSourceWell.value = null dragTargetWell.value = null return } const droppedSample = parseDroppedSample(event, well.id) if (droppedSample) options.emitSampleDrop(well.id, droppedSample, event) dragSourceWell.value = null dragTargetWell.value = null } function handleDragEnd() { dragSourceWell.value = null dragTargetWell.value = null } // Keyboard alternative to the well-to-well drag. The plate is a 2D grid, not a // list, so it cannot use `useListReorder`'s index maths: instead Alt+Enter (or // Alt+Space) picks a filled well up, the same chord on another well drops it // there, and Escape cancels. Every step is announced politely. function handleWellMoveKeydown(well: Well, event: KeyboardEvent) { if (options.disabled() || options.readonly() || options.selectionMode() !== 'drag') return if (event.key === 'Escape' && dragSourceWell.value) { event.preventDefault() announce(`Move cancelled. ${dragSourceWell.value} stays in place.`) dragSourceWell.value = null dragTargetWell.value = null return } if (!event.altKey || (event.key !== 'Enter' && event.key !== ' ')) return event.preventDefault() const source = dragSourceWell.value if (!source) { if (!well.sampleType) { announce(`Well ${well.id} is empty and cannot be moved.`) return } dragSourceWell.value = well.id announce(`Well ${well.id} picked up. Move to another well and press Alt and Enter to drop it.`) return } if (source !== well.id) { options.emitWellMove(source, well.id) announce(`Well ${source} moved to ${well.id}.`) } else { announce(`Move cancelled. ${source} stays in place.`) } dragSourceWell.value = null dragTargetWell.value = null } function parseDroppedSample(event: DragEvent, wellId: string): WellSampleDropData | null { const customParser = options.sampleDropParser() if (customParser) return customParser(event, { wellId, event }) ?? null const transfer = event.dataTransfer if (!transfer) return null return parseDroppedSampleTransfer(transfer) } function handleKeyDown(event: KeyboardEvent) { if (options.disabled() || options.readonly()) return if (event.key === 'Escape') { if (editingWellId.value) { editingWellId.value = null return } anchorWellId = null emitSelected([]) } if ((event.key === 'a' || event.key === 'A') && (event.metaKey || event.ctrlKey)) { event.preventDefault() emitSelected(options.wellGrid().flat().map(w => w.id)) } } return { dragSourceWell, dragTargetWell, isDragging, dragStart, dragEnd, hoveredWell, editingWellId, editPopupPosition, selectedWellSet, dragSelectedWells, isSelected, handleWellClick, handleEditSave, handleEditClear, handleEditClose, handleWellMouseDown, handleWellMouseEnter, handleWellMouseLeave, handleMouseUp, handleContextMenu, handleDragStart, handleDragOver, handleDragLeave, handleDrop, handleDragEnd, handleWellMoveKeydown, handleKeyDown, moveAnnouncement: announcement, moveAnnouncerStyle: announcerStyle, } }