diff --git a/eslint.config.mjs b/eslint.config.mjs index 11726daf..dcdedcde 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -119,6 +119,7 @@ export default [ }, ], 'react/react-in-jsx-scope': 0, + curly: ['warn', 'all'], }, }, ]; diff --git a/src/midiEditor/CVOutput/CVOutputControls.svelte b/src/midiEditor/CVOutput/CVOutputControls.svelte index 4854ca29..0441fae1 100644 --- a/src/midiEditor/CVOutput/CVOutputControls.svelte +++ b/src/midiEditor/CVOutput/CVOutputControls.svelte @@ -16,6 +16,7 @@ export let view: Writable; export let getCursorPosBeats: () => number; export let setCursorPosBeats: (newCursorPosBeats: number) => void; + export let activateDrag: () => void; const expand = () => { $state.isExpanded = true; @@ -26,7 +27,7 @@ {#if !$state.isExpanded} - + {:else} {/if} diff --git a/src/midiEditor/CVOutput/CVOutputControlsInner.svelte b/src/midiEditor/CVOutput/CVOutputControlsInner.svelte index e97b4e44..a5727849 100644 --- a/src/midiEditor/CVOutput/CVOutputControlsInner.svelte +++ b/src/midiEditor/CVOutput/CVOutputControlsInner.svelte @@ -15,6 +15,7 @@ import { mkCVOutputSettingsPopup } from './CVOutputSettingsPopup'; import type { MIDIEditorBaseView } from 'src/midiEditor'; import Cursor from 'src/midiEditor/CVOutput/Cursor.svelte'; + import SvelteDragHandle from 'src/midiEditor/SvelteDragHandle.svelte'; export let name: string; export let setName: (name: string) => void; @@ -26,6 +27,7 @@ export let view: MIDIEditorBaseView; export let getCursorPosBeats: () => number; export let setCursorPosBeats: (newCursorPosBeats: number) => void; + export let activateDrag: () => void; let width: number | undefined; let widthObserver: ResizeObserver | undefined; @@ -54,7 +56,11 @@ role="button" > ⌄ - + + diff --git a/src/midiEditor/CVOutput/CollapsedCVOutputControls.svelte b/src/midiEditor/CVOutput/CollapsedCVOutputControls.svelte index c6f7bd38..7a80107e 100644 --- a/src/midiEditor/CVOutput/CollapsedCVOutputControls.svelte +++ b/src/midiEditor/CVOutput/CollapsedCVOutputControls.svelte @@ -1,10 +1,12 @@
- › + › +
diff --git a/src/midiEditor/CollapsedMIDIEditor.svelte b/src/midiEditor/CollapsedMIDIEditor.svelte index 1e29e56b..6f0c21ef 100644 --- a/src/midiEditor/CollapsedMIDIEditor.svelte +++ b/src/midiEditor/CollapsedMIDIEditor.svelte @@ -5,6 +5,7 @@ import { PIANO_KEYBOARD_WIDTH } from 'src/midiEditor/conf'; import type { ManagedMIDIEditorUIInstance } from 'src/midiEditor/MIDIEditorUIManager'; import EditableInstanceName from './EditableInstanceName.svelte'; + import SvelteDragHandle from 'src/midiEditor/SvelteDragHandle.svelte'; export let parentInstance: MIDIEditorInstance; export let inst: ManagedMIDIEditorUIInstance; @@ -12,6 +13,7 @@ export let scrollHorizontalBeats: Readable; export let expand: () => void; export let instIx: number; + export let activateDrag: () => void; let minimapContainer: HTMLDivElement | null = null; let svg: SVGSVGElement | null = null; @@ -54,6 +56,10 @@ > › + parentInstance.uiManager.renameInstance(inst.name, newName)} transparent diff --git a/src/midiEditor/DnD.tsx b/src/midiEditor/DnD.tsx new file mode 100644 index 00000000..282463fe --- /dev/null +++ b/src/midiEditor/DnD.tsx @@ -0,0 +1,100 @@ +import { createContext, useCallback, useRef } from 'react'; +import { useDrag, useDrop } from 'react-dnd'; + +const ItemTypes = { + INSTANCE: 'instance', +}; + +export const DragActivationContext = createContext<() => void>(() => {}); + +interface DragHandleProps { + activateDrag: () => void; + style?: React.CSSProperties; +} + +export const DragHandle: React.FC = ({ style, activateDrag }) => ( +
{ + e.stopPropagation(); + activateDrag(); + }} + /> +); + +interface DraggableInstanceProps { + index: number; + instanceKey: string; + moveInstance: (dragIndex: number, hoverIndex: number) => void; + children: React.ReactNode; +} + +export const DraggableInstance: React.FC = ({ + index, + instanceKey, + moveInstance, + children, +}) => { + const ref = useRef(null); + const dragAllowedRef = useRef(false); + + const [{ isDragging }, drag] = useDrag({ + type: ItemTypes.INSTANCE, + item: { index }, + canDrag: () => dragAllowedRef.current, + collect: monitor => ({ isDragging: monitor.isDragging() }), + end: () => { + dragAllowedRef.current = false; + }, + }); + + drag(ref); + + const [, drop] = useDrop({ + accept: ItemTypes.INSTANCE, + hover(item: { index: number }, monitor) { + if (!ref.current) { + return; + } + + const dragIndex = item.index; + const hoverIndex = index; + if (dragIndex === hoverIndex) { + return; + } + + const hoverBoundingRect = ref.current.getBoundingClientRect(); + const boundingHeight = hoverBoundingRect.bottom - hoverBoundingRect.top; + const hoverCutoffY = boundingHeight < 100 ? boundingHeight / 2 : 50; + const clientOffset = monitor.getClientOffset(); + if (!clientOffset) { + return; + } + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + if ( + (dragIndex < hoverIndex && hoverClientY < hoverCutoffY) || + (dragIndex > hoverIndex && hoverClientY > hoverCutoffY) + ) { + return; + } + moveInstance(dragIndex, hoverIndex); + item.index = hoverIndex; + }, + }); + + drop(ref); + + const activateDrag = useCallback(() => { + dragAllowedRef.current = true; + }, []); + + return ( +
+ + {children} + +
+ ); +}; diff --git a/src/midiEditor/MIDIEditor.css b/src/midiEditor/MIDIEditor.css index a1cc48f3..07012759 100644 --- a/src/midiEditor/MIDIEditor.css +++ b/src/midiEditor/MIDIEditor.css @@ -81,6 +81,26 @@ } } } + + .drag-handle { + width: 30px; + height: 18px; + border: 1px solid #999; + cursor: grab; + + /* vertical ridges to indicate that it's a handle */ + background-image: repeating-linear-gradient( + 90deg, + #888, + #888 2px, + #444 1px, + #444 4px + ); + } + + .drag-handle:active { + cursor: grabbing; + } } .expanded-midi-editor-instance { diff --git a/src/midiEditor/MIDIEditor.tsx b/src/midiEditor/MIDIEditor.tsx index f5bbd03a..c608a0b9 100644 --- a/src/midiEditor/MIDIEditor.tsx +++ b/src/midiEditor/MIDIEditor.tsx @@ -1,7 +1,9 @@ import download from 'downloadjs'; import * as R from 'ramda'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import ControlPanel from 'react-control-panel'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; import './MIDIEditor.css'; @@ -27,6 +29,7 @@ import MIDIEditorUIInstance from 'src/midiEditor/MIDIEditorUIInstance'; import type { ManagedInstance, MIDIEditorUIManager } from 'src/midiEditor/MIDIEditorUIManager'; import type MIDIEditorPlaybackHandler from 'src/midiEditor/PlaybackHandler'; import EditableInstanceName from './EditableInstanceName.svelte'; +import { DragActivationContext, DraggableInstance, DragHandle } from './DnD'; const MIDIWasmModule = new AsyncOnce(() => import('src/midi')); @@ -205,7 +208,7 @@ const handleMIDIFileUpload = async ( } catch (err) { if (err) { console.error('Error importing MIDI: ', err); - // TODO: Display to user? + toastError(`Error importing MIDI: ${err}`); } } }; @@ -555,6 +558,132 @@ const CollapsedMIDIEditorShim = mkSvelteComponentShim(CollapsedMIDIEditor); const EditableInstanceNameShim = mkSvelteComponentShim(EditableInstanceName); +interface MIDIEditorInstanceCompProps { + instance: Extract; + instIx: number; + parentInstance: MIDIEditorInstance; + lastCanvasRefsByInstID: React.MutableRefObject<{ [key: string]: HTMLCanvasElement }>; + windowSize: { width: number; height: number }; + vcId: string; +} + +const MIDIEditorInstanceComp: React.FC = ({ + instance, + instIx, + parentInstance, + lastCanvasRefsByInstID, + windowSize, + vcId, +}) => { + const activateDrag = useContext(DragActivationContext); + const inst = instance.instance; + + if (!instance.isExpanded) { + return ( +
+ parentInstance.uiManager.expandUIInstance(inst.id)} + instIx={instIx} + activateDrag={activateDrag} + /> +
+ ); + } + + return ( +
+
+ + + parentInstance.uiManager.renameInstance(inst.name, newName)} + /> + +
+ { + if (!canvas) { + return; + } + if (canvas === lastCanvasRefsByInstID.current[inst.id]) { + return; + } + lastCanvasRefsByInstID.current[inst.id] = canvas; + + parentInstance.uiManager.getUIInstanceByID(inst.id)?.destroy(); + const managedInst = parentInstance.uiManager.getMIDIEditorInstanceByID(inst.id)!; + const instanceHeight = parentInstance.uiManager.computeUIInstanceHeight(); + const newInst = new MIDIEditorUIInstance( + windowSize.width, + instanceHeight, + canvas, + parentInstance, + managedInst, + vcId + ); + parentInstance.uiManager.setUIInstanceForID(inst.id, newInst); + }} + onMouseDown={evt => { + parentInstance.uiManager.setActiveUIInstanceID(inst.id); + evt.preventDefault(); + evt.stopPropagation(); + }} + onContextMenu={evt => { + evt.preventDefault(); + evt.stopPropagation(); + }} + /> +
+ ); +}; + +interface CVOutputInstanceCompProps { + output: Extract['instance']; + parentInstance: MIDIEditorInstance; +} + +const CVOutputInstanceComp: React.FC = ({ output, parentInstance }) => { + const activateDrag = useContext(DragActivationContext); + + return ( +
+ parentInstance.deleteCVOutput(output.name)} + setName={newName => parentInstance.renameCVOutput(output.name, newName)} + registerInstance={uiInstance => output.registerUIInstance(uiInstance)} + setFrozenOutputValue={newFrozenOutputValue => + output.backend.setFrozenOutputValue(newFrozenOutputValue) + } + view={parentInstance.baseView.store} + getCursorPosBeats={() => parentInstance.playbackHandler.getCursorPosBeats()} + setCursorPosBeats={newCursorPosBeats => + void parentInstance.playbackHandler.setCursorPosBeats(newCursorPosBeats) + } + activateDrag={activateDrag} + /> +
+ ); +}; + class ActiveInstanceProxy { private uiManager: MIDIEditorUIManager; @@ -620,118 +749,66 @@ const MIDIEditor: React.FC = ({ [parentInstance.uiManager] ); + const moveInstance = useCallback( + (dragIndex: number, hoverIndex: number) => { + const updatedInstances = [...instances]; + const [removed] = updatedInstances.splice(dragIndex, 1); + updatedInstances.splice(hoverIndex, 0, removed); + parentInstance.uiManager.instances.set(updatedInstances); + }, + [instances, parentInstance.uiManager] + ); + return ( -
- -
-
- {instances.map((instance, instIx) => { - if (instance.type === 'midiEditor') { - const inst = instance.instance; - if (instance.isExpanded) { + +
+ +
+
+ {instances.map((instance, instIx) => { + if (instance.type === 'midiEditor') { return ( -
- - parentInstance.uiManager.renameInstance(inst.name, newName)} - /> - - { - if (!canvas) { - return; - } - - if (canvas === lastCanvasRefsByInstID.current[inst.id]) { - return; - } - lastCanvasRefsByInstID.current[inst.id] = canvas; - - parentInstance.uiManager.getUIInstanceByID(inst.id)?.destroy(); - const managedInst = parentInstance.uiManager.getMIDIEditorInstanceByID( - inst.id - )!; - const instanceHeight = parentInstance.uiManager.computeUIInstanceHeight(); - const newInst = new MIDIEditorUIInstance( - windowSize.width, - instanceHeight, - canvas, - parentInstance, - managedInst, - vcId - ); - parentInstance.uiManager.setUIInstanceForID(inst.id, newInst); - }} - onMouseDown={evt => { - parentInstance.uiManager.setActiveUIInstanceID(inst.id); - evt.preventDefault(); - evt.stopPropagation(); - }} - onContextMenu={evt => { - evt.preventDefault(); - evt.stopPropagation(); - }} + + -
+ ); + } else if (instance.type === 'cvOutput') { + const output = instance.instance; + return ( + + + + ); + } else { + throw new Error('Unknown instance type'); } - - return ( - parentInstance.uiManager.expandUIInstance(inst.id)} - instIx={instIx} - /> - ); - } else if (instance.type === 'cvOutput') { - const output = instance.instance; - return ( - parentInstance.deleteCVOutput(output.name)} - setName={newName => parentInstance.renameCVOutput(output.name, newName)} - registerInstance={uiInstance => output.registerUIInstance(uiInstance)} - setFrozenOutputValue={newFrozenOutputValue => - output.backend.setFrozenOutputValue(newFrozenOutputValue) - } - view={parentInstance.baseView.store} - getCursorPosBeats={() => parentInstance.playbackHandler.getCursorPosBeats()} - setCursorPosBeats={newCursorPosBeats => - void parentInstance.playbackHandler.setCursorPosBeats(newCursorPosBeats) - } - /> - ); - } else { - throw new Error('Unknown instance type'); - } - })} + })} +
-
+
); }; diff --git a/src/midiEditor/SvelteDragHandle.svelte b/src/midiEditor/SvelteDragHandle.svelte new file mode 100644 index 00000000..4662d1b2 --- /dev/null +++ b/src/midiEditor/SvelteDragHandle.svelte @@ -0,0 +1,11 @@ + + + diff --git a/src/midiEditor/index.ts b/src/midiEditor/index.ts index 4cf6fcb0..0aab69ae 100644 --- a/src/midiEditor/index.ts +++ b/src/midiEditor/index.ts @@ -285,7 +285,7 @@ export class MIDIEditorInstance { } /** - * Retruns `true` if the loop point was actually updated and `false` if it wasn't updated due to + * Returns `true` if the loop point was actually updated and `false` if it wasn't updated due to * playback currently being active or something else. */ public setLoopPoint(loopPoint: number | null): boolean {