- { editingSong &&
+ { model &&
}
@@ -42,23 +37,29 @@ export class MusicFieldEditor extends React.Component
{
- this.mostRecentValue = newSong;
+ model.restoreState(value);
+ }
}
protected onAssetNameChanged = (newName: string) => {
- this.setState({
- editingSong: {
- ...this.state.editingSong,
- meta: {
- ...this.state.editingSong.meta,
- displayName: newName
- }
- }
- })
+ const currentValue = this.state.model?.getCurrentValue();
+
+ if (currentValue) {
+ currentValue.meta.displayName = newName;
+ this.state.model!.updateValue(currentValue);
+ }
}
}
\ No newline at end of file
diff --git a/webapp/src/components/PianoRollFieldEditor.tsx b/webapp/src/components/PianoRollFieldEditor.tsx
index e6df37ebedd0..4b01b3a67602 100644
--- a/webapp/src/components/PianoRollFieldEditor.tsx
+++ b/webapp/src/components/PianoRollFieldEditor.tsx
@@ -1,8 +1,8 @@
import * as React from "react";
+import { PianoRoll, PianoRollModel } from "./pianoRoll/PianoRoll";
-import { FieldEditorComponent } from '../blocklyFieldView';
import { AssetEditorCore } from "./ImageFieldEditor";
-import { PianoRollFieldEditor } from "./pianoRoll/FieldEditor";
+import { fromPXTSong, toPXTSong } from "./pianoRoll/types";
interface PianoRollAssetEditorProps {
onDoneClicked: () => void;
@@ -10,41 +10,98 @@ interface PianoRollAssetEditorProps {
fieldEditorParams?: any;
}
-export class PianoRollAssetEditor extends React.Component implements AssetEditorCore {
- protected pianoRollFieldEditorRef: FieldEditorComponent;
+interface PianoRollEditorState {
+ model?: PianoRollModel;
+}
+
+
+export class PianoRollAssetEditor extends React.Component implements AssetEditorCore {
protected openedAsset: pxt.Song;
constructor(props: PianoRollAssetEditorProps) {
super(props);
- this.state = {
- editRef: 0
- }
+ this.state = {};
}
render() {
+ const { fieldEditorParams, onDoneClicked } = this.props;
+ const { model } = this.state;
+
return (
-
+ <>
+ {model &&
+
+ }
+ >
)
}
getAsset(): pxt.Song {
- return this.pianoRollFieldEditorRef?.getValue();
+ if (this.state.model) {
+ const { song, assetName } = this.state.model.getCurrentValue();
+ const pxtSong = toPXTSong(song);
+
+ const result: pxt.Song = {
+ ...this.openedAsset,
+ song: pxtSong
+ };
+
+ if (this.openedAsset?.meta || assetName) {
+ result.meta = {
+ ...(this.openedAsset?.meta || {}),
+ displayName: assetName
+ }
+ }
+
+ return result;
+ }
+
+ return this.openedAsset;
}
openAsset(value: pxt.Song) {
- if (this.pianoRollFieldEditorRef) {
- this.pianoRollFieldEditorRef.init(value, this.props.onDoneClicked);
+ this.openedAsset = value;
+
+ const song = fromPXTSong(value.song);
+ const newValue = {
+ song,
+ assetName: value.meta?.displayName,
+ selectedTrackIndex: 0
+ };
+
+ if (this.state.model) {
+ this.state.model.updateValue(newValue, { preserveUndo: false });
}
else {
- this.openedAsset = value;
+ this.setState({
+ model: new PianoRollModel(newValue)
+ });
}
}
openGalleryAsset(asset: pxt.Asset): void {
- // TODO
+ const pxtSong = pxt.cloneAsset(asset as pxt.Song);
+ pxt.assets.music.inflateSong(pxtSong.song);
+
+ const song = fromPXTSong(pxtSong.song);
+
+ // remap the track ids to be nice
+ for (let i = 0; i < song.tracks.length; i++) {
+ const track = song.tracks[i];
+ track.id = i + 1;
+ }
+
+ const currentValue = this.state.model!.getCurrentValue();
+ this.state.model!.updateValue({
+ ...currentValue,
+ song,
+ selectedTrackIndex: 0
+ }, { preserveUndo: true, pushUndo: true });
}
getJres(): string {
@@ -64,18 +121,18 @@ export class PianoRollAssetEditor extends React.Component) => {
- this.pianoRollFieldEditorRef = ref;
- if (this.openedAsset) {
- this.pianoRollFieldEditorRef?.init(this.openedAsset, this.props.onDoneClicked);
- this.openedAsset = undefined;
+ if (value) {
+ let model = this.state.model;
+ if (!model) {
+ model = new PianoRollModel();
+ this.setState({ model });
+ }
+
+ model.restoreState(value);
}
}
}
\ No newline at end of file
diff --git a/webapp/src/components/SongGallery.tsx b/webapp/src/components/SongGallery.tsx
new file mode 100644
index 000000000000..e23e9de9fd4e
--- /dev/null
+++ b/webapp/src/components/SongGallery.tsx
@@ -0,0 +1,114 @@
+import { useEffect, useMemo, useState } from "react";
+import { songToDataURI } from "../../../pxtblocks";
+import { Button } from "../../../react-common/components/controls/Button";
+import { FocusTrapRegion } from "../../../react-common/components/controls/FocusTrap";
+import { classList } from "../../../react-common/components/util";
+import { addPlaybackStateListener, removePlaybackStateListener, startPlaybackAsync, stopPlayback } from "./musicEditor/playback";
+
+interface SongGalleryProps {
+ items?: pxt.Asset[];
+ hidden: boolean;
+ onAssetSelected: (item: pxt.Asset) => void;
+ onEscape: () => void;
+}
+
+export const SongGallery = (props: SongGalleryProps) => {
+ const { items, hidden, onAssetSelected, onEscape } = props;
+
+ const [playingSong, setPlayingSong] = useState(null);
+
+ useEffect(() => {
+ const playbackStateListener = (state: "play" | "stop" | "loop") => {
+ if (state === "stop") {
+ setPlayingSong(null);
+ }
+ }
+
+ addPlaybackStateListener(playbackStateListener);
+ return () => {
+ removePlaybackStateListener(playbackStateListener);
+ }
+ }, []);
+
+ const onPlayClick = (song: pxt.assets.music.Song) => {
+ if (playingSong === song) {
+ stopPlayback();
+ setPlayingSong(null);
+ }
+ else {
+ startPlaybackAsync(song, false);
+ setPlayingSong(song);
+ }
+ };
+
+ return (
+
+ {!hidden && items?.map((item, index) =>
+ onAssetSelected(item)}
+ />
+ )}
+
+ );
+}
+
+interface SongGalleryItemProps {
+ asset: pxt.Asset;
+ selected: boolean;
+ isPlaying: boolean;
+ onClick: () => void;
+ onPlayClick: (song: pxt.assets.music.Song) => void;
+}
+
+const SongGalleryItem = (props: SongGalleryItemProps) => {
+ const { asset, selected, isPlaying, onClick, onPlayClick } = props;
+
+ const song = (asset as pxt.Song).song;
+
+ const previewUri = useMemo(() => {
+ return songToDataURI((asset as pxt.Song).song, 100, 32, false);
+ }, [asset.id]);
+
+ const handlePlayClick = () => {
+ onPlayClick(song);
+ };
+
+ const isTemporary = !!asset.meta.temporaryInfo;
+ const displayName = isTemporary ? lf("{id:song}Untitled") : (asset.meta?.displayName || asset.id.split(".").pop() || asset.id);
+
+ return (
+
+
+ );
+}
\ No newline at end of file
diff --git a/webapp/src/components/musicEditor/AssetModel.ts b/webapp/src/components/musicEditor/AssetModel.ts
new file mode 100644
index 000000000000..69dbb6afa688
--- /dev/null
+++ b/webapp/src/components/musicEditor/AssetModel.ts
@@ -0,0 +1,144 @@
+import * as React from "react";
+
+interface UpdateOptions {
+ preserveUndo?: boolean;
+ pushUndo?: boolean;
+}
+
+interface RestoreStateOptions {
+ overwriteValue?: boolean;
+ includeRedoStack?: boolean;
+}
+
+export abstract class AssetModel {
+ protected currentValue: T;
+ protected undoStack: T[] = [];
+ protected redoStack: T[] = [];
+
+ protected extraState: U;
+
+ protected abstract cloneValue(value: T): T;
+ protected abstract cloneExtraState(value: U): U;
+
+ protected listeners: (() => void)[] = [];
+
+ constructor(initialValue?: T) {
+ if (initialValue !== undefined) {
+ this.currentValue = this.cloneValue(initialValue);
+ }
+ }
+
+ public addChangeListener(listener: () => void) {
+ this.listeners.push(listener);
+ }
+
+ public removeChangeListener(listener: () => void) {
+ this.listeners = this.listeners.filter(l => l !== listener);
+ }
+
+ protected notifyChange() {
+ this.listeners.forEach(l => l());
+ }
+
+ public updateValue(newValue: T, options: UpdateOptions = {}) {
+ const { preserveUndo = true, pushUndo = true } = options;
+ if (preserveUndo) {
+ if (pushUndo && this.currentValue !== undefined) {
+ this.undoStack.push(this.currentValue);
+ }
+ }
+ else {
+ this.undoStack = [];
+ }
+
+ this.redoStack = [];
+ this.currentValue = this.cloneValue(newValue);
+ this.notifyChange();
+ }
+
+ public updateExtraState(newExtraState: U) {
+ this.extraState = this.cloneExtraState(newExtraState);
+ this.notifyChange();
+ }
+
+ public undo() {
+ if (this.undoStack.length > 0) {
+ this.redoStack.push(this.currentValue);
+ this.currentValue = this.undoStack.pop()!;
+ this.notifyChange();
+ }
+ }
+
+ public redo() {
+ if (this.redoStack.length > 0) {
+ this.undoStack.push(this.currentValue);
+ this.currentValue = this.redoStack.pop()!;
+ this.notifyChange();
+ }
+ }
+
+ public getCurrentValue(): T {
+ return this.cloneValue(this.currentValue);
+ }
+
+ public getExtraState(): U {
+ return this.cloneExtraState(this.extraState);
+ }
+
+ public getState(): { currentValue: T; undoStack: T[]; redoStack: T[]; extraState: U } {
+ return {
+ currentValue: this.cloneValue(this.currentValue),
+ undoStack: this.undoStack.map(v => this.cloneValue(v)),
+ redoStack: this.redoStack.map(v => this.cloneValue(v)),
+ extraState: this.cloneExtraState(this.extraState)
+ };
+ }
+
+ public restoreState(state: { currentValue: T; undoStack: T[]; redoStack: T[]; extraState: U }, options: RestoreStateOptions = {}) {
+ if (options.overwriteValue) {
+ this.currentValue = this.cloneValue(state.currentValue);
+ }
+
+ if (options.includeRedoStack) {
+ this.redoStack = state.redoStack.map(v => this.cloneValue(v));
+ }
+
+ this.undoStack = state.undoStack.map(v => this.cloneValue(v));
+ this.extraState = this.cloneExtraState(state.extraState);
+ this.notifyChange();
+ }
+
+ public hasUndo(): boolean {
+ return this.undoStack.length > 0;
+ }
+
+ public hasRedo(): boolean {
+ return this.redoStack.length > 0;
+ }
+}
+
+export class SongModel extends AssetModel {
+ protected cloneValue(value: pxt.Song): pxt.Song {
+ return pxt.cloneAsset(value);
+ }
+
+ protected cloneExtraState(value: {}): {} {
+ return value;
+ }
+}
+
+export const useModelValue = (model: AssetModel) => {
+ const [value, setValue] = React.useState(model.getCurrentValue());
+
+ React.useEffect(() => {
+ const handleChange = () => {
+ setValue(model.getCurrentValue());
+ };
+ model.addChangeListener(handleChange);
+ return () => {
+ model.removeChangeListener(handleChange);
+ };
+ }, [model]);
+
+ return { value, hasUndo: model.hasUndo(), hasRedo: model.hasRedo(), extraState: model.getExtraState() };
+}
\ No newline at end of file
diff --git a/webapp/src/components/musicEditor/GridHighlight.tsx b/webapp/src/components/musicEditor/GridHighlight.tsx
deleted file mode 100644
index 370449877a47..000000000000
--- a/webapp/src/components/musicEditor/GridHighlight.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { tickToX, STAFF_HEADER_HEIGHT, STAFF_GRID_TICK_HEIGHT, BASS_STAFF_TOP } from "./svgConstants";
-
-export interface GridHighlightProps extends pxt.assets.music.SongInfo {
- start?: number;
- end?: number;
-}
-
-export const GridHighlight = (props: GridHighlightProps) => {
- const { start, end, ticksPerBeat } = props;
-
- const hasGridHighlight = start != undefined && end != undefined;
-
- const s = Math.min(start, end);
- const e = Math.max(start, end);
-
- if (!hasGridHighlight) return ;
-
- return
-
-
-
-}
\ No newline at end of file
diff --git a/webapp/src/components/musicEditor/MusicEditor.tsx b/webapp/src/components/musicEditor/MusicEditor.tsx
index f582d50a84c9..b444b7359c8b 100644
--- a/webapp/src/components/musicEditor/MusicEditor.tsx
+++ b/webapp/src/components/musicEditor/MusicEditor.tsx
@@ -1,4 +1,5 @@
import * as React from "react";
+import { SongModel, useModelValue } from "./AssetModel";
import { EditControls } from "./EditControls";
import { CursorState, handleKeyboardEvent } from "./keyboardNavigation";
import { isPlaying, updatePlaybackSongAsync, stopPlayback, startPlaybackAsync } from "./playback";
@@ -8,12 +9,9 @@ import { GridResolution, TrackSelector } from "./TrackSelector";
import { addNoteToTrack, changeSongLength, editNoteEventLength, fillDrums, findPreviousNoteEvent, findNoteEventAtPosition, findSelectedRange, noteToRow, removeNoteFromTrack, rowToNote, selectNoteEventsInRange, unselectAllNotes, applySelection as applySelectionMove, deleteSelectedNotes, applySelection, removeNoteAtRowFromTrack, isBassClefNote, doesSongUseBassClef, findNoteEventsOverlappingRange } from "./utils";
export interface MusicEditorProps {
- asset: pxt.Song;
- onSongChanged?: (newValue: pxt.assets.music.Song) => void;
- savedUndoStack?: pxt.assets.music.Song[];
+ model: SongModel;
onAssetNameChanged: (newName: string) => void;
onDoneClicked: () => void;
- editRef: number;
hideDoneButton?: boolean;
}
@@ -30,19 +28,18 @@ interface DragState {
}
export const MusicEditor = (props: MusicEditorProps) => {
- const { asset, onSongChanged, savedUndoStack, onAssetNameChanged, editRef, onDoneClicked, hideDoneButton} = props;
+ const { model, onAssetNameChanged, onDoneClicked, hideDoneButton } = props;
+ const { value, hasUndo, hasRedo } = useModelValue(model);
+ const currentSong = value.song;
+
const [selectedTrack, setSelectedTrack] = React.useState(0);
const [gridResolution, setGridResolution] = React.useState("1/8");
- const [currentSong, setCurrentSong] = React.useState(asset.song);
const [eraserActive, setEraserActive] = React.useState(false);
const [hideTracksActive, setHideTracksActive] = React.useState(false);
- const [undoStack, setUndoStack] = React.useState(savedUndoStack || []);
- const [redoStack, setRedoStack] = React.useState([]);
- const [editingId, setEditingId] = React.useState(editRef);
const [selection, setSelection] = React.useState();
const [cursor, setCursor] = React.useState();
const [cursorVisible, setCursorVisible] = React.useState(false);
- const [bassClefVisible, setBassClefVisible] = React.useState(doesSongUseBassClef(asset.song));
+ const [bassClefVisible, setBassClefVisible] = React.useState(doesSongUseBassClef(currentSong));
React.useEffect(() => {
return () => {
@@ -115,11 +112,6 @@ export const MusicEditor = (props: MusicEditorProps) => {
}
}, [selection, hideTracksActive, currentSong])
- if (editingId !== editRef) {
- setEditingId(editRef);
- setCurrentSong(asset.song);
- }
-
const dragState = React.useRef()
const gridTicks = eraserActive ? 1 : gridResolutionToTicks(gridResolution, currentSong.ticksPerBeat);
@@ -129,14 +121,19 @@ export const MusicEditor = (props: MusicEditorProps) => {
if (isPlaying()) {
updatePlaybackSongAsync(newSong);
}
- let newUndoStack = undoStack.slice()
- if (pushUndo) {
- newUndoStack.push(pxt.assets.music.cloneSong(selection?.originalSong || dragState.current?.original || currentSong));
- setUndoStack(newUndoStack);
- setRedoStack([]);
- }
- setCurrentSong(newSong);
- if (onSongChanged) onSongChanged(newSong);
+
+ const currentValue = model.getCurrentValue();
+ model.updateValue(
+ {
+ ...currentValue,
+ song: newSong
+ },
+ {
+ preserveUndo: true,
+ pushUndo
+ }
+ );
+
if (dragState.current) {
dragState.current.editing = newSong;
}
@@ -544,19 +541,11 @@ export const MusicEditor = (props: MusicEditorProps) => {
}
const undo = () => {
- if (!undoStack.length) return;
- setRedoStack(redoStack.concat([currentSong]));
- const toRestore = undoStack.pop();
- setUndoStack(undoStack.slice());
- updateSong(toRestore, false);
+ model.undo();
}
const redo = () => {
- if (!redoStack.length) return;
- setUndoStack(undoStack.concat([currentSong]));
- const toRestore = redoStack.pop();
- setRedoStack(redoStack.slice());
- updateSong(toRestore, false);
+ model.redo();
}
const onEraserClick = () => {
@@ -653,10 +642,10 @@ export const MusicEditor = (props: MusicEditorProps) => {
onRedoClick={redo}
showBassClef={bassClefVisible}
onBassClefCheckboxClick={setBassClefVisible}
- hasUndo={!!undoStack.length}
- hasRedo={!!redoStack.length} />
+ hasUndo={hasUndo}
+ hasRedo={hasRedo} />
diff --git a/webapp/src/components/musicEditor/Track.tsx b/webapp/src/components/musicEditor/Track.tsx
index 5c89a3ebb95c..0a9d1a4790fc 100644
--- a/webapp/src/components/musicEditor/Track.tsx
+++ b/webapp/src/components/musicEditor/Track.tsx
@@ -1,6 +1,5 @@
import * as React from "react";
import { CursorState } from "./keyboardNavigation";
-import { Note } from "./Note";
import { NoteGroup } from "./NoteGroup";
import { BASS_STAFF_TOP, STAFF_HEADER_HEIGHT, tickToX, WORKSPACE_HEIGHT } from "./svgConstants";
@@ -8,22 +7,10 @@ export interface TrackProps {
song: pxt.assets.music.Song;
track: pxt.assets.music.Track;
keyboardCursor?: CursorState;
- cursorLocation?: WorkspaceCoordinate;
}
export const Track = (props: TrackProps) => {
- const { song, track, cursorLocation, keyboardCursor } = props;
-
- let cursorElement: JSX.Element;
- if (cursorLocation) {
- cursorElement =
-
-
- }
+ const { song, track, keyboardCursor } = props;
return
{keyboardCursor &&
@@ -39,7 +26,7 @@ export const Track = (props: TrackProps) => {
}
{track.notes.map(noteEvent =>
{
isDrumTrack={!!track.drums}
cursor={keyboardCursor} />
)}
- { cursorElement }
-}
\ No newline at end of file
+}
+
+const noteEventKey = (noteEvent: pxt.assets.music.NoteEvent) => `${noteEvent.startTick}-${noteEvent.endTick}-${noteEvent.notes.map(n => n.note + n.enharmonicSpelling).join("-")}`;
\ No newline at end of file
diff --git a/webapp/src/components/musicEditor/Workspace.tsx b/webapp/src/components/musicEditor/Workspace.tsx
index a9bfd8148dad..635b1598314e 100644
--- a/webapp/src/components/musicEditor/Workspace.tsx
+++ b/webapp/src/components/musicEditor/Workspace.tsx
@@ -1,11 +1,10 @@
import * as React from "react";
import { classList, clientCoord, screenToSVGCoord } from "../../../../react-common/components/util";
-import { GridHighlight } from "./GridHighlight";
import { CursorState } from "./keyboardNavigation";
import { Staff } from "./Staff";
-import { BASS_STAFF_TOP, BEAT_WIDTH, closestRow, closestTick, workspaceWidth, WORKSPACE_HEIGHT } from "./svgConstants";
+import { BASS_STAFF_TOP, BEAT_WIDTH, closestRow, closestTick, NOTE_ICON_WIDTH, rowY, STAFF_GRID_TICK_HEIGHT, STAFF_HEADER_HEIGHT, tickToX, workspaceWidth, WORKSPACE_HEIGHT } from "./svgConstants";
import { Track } from "./Track";
-import { findNoteEventAtTick } from "./utils";
+import { findNoteEventAtTick, resolveImageURL } from "./utils";
import { WorkspaceSelection } from "./WorkspaceSelection";
export interface WorkspaceProps {
@@ -27,13 +26,81 @@ export interface WorkspaceProps {
export const Workspace = (props: WorkspaceProps) => {
const { song, onWorkspaceClick, gridTicks, selectedTrack, onWorkspaceDrag, onWorkspaceDragStart, onWorkspaceDragEnd, onKeydown, cursor, hideUnselectedTracks, eraserActive, showBassClef, selection } = props;
- const [cursorLocation, setCursorLocation] = React.useState(null);
const [dragStart, setDragStart] = React.useState(null);
const [isDragging, setIsDragging] = React.useState(false);
let workspaceRef: SVGSVGElement;
+ const cursorLocationRef = React.useRef(null);
+ const trebleGridHighlightRef = React.useRef(null);
+ const bassGridHighlightRef = React.useRef(null);
+ const cursorPreviewLocationRef = React.useRef(null);
+
React.useEffect(() => {
+ const setCursorLocation = (coord: WorkspaceClickCoordinate) => {
+ cursorLocationRef.current = coord;
+
+ const cursorLocation = cursorLocationRef.current;
+
+ let gridHighlightStart: number;
+ let gridHighlightEnd: number;
+
+ let cursorPreviewLocation = (isDragging || eraserActive) ? undefined : cursorLocation;
+ const eventAtCursor = cursorLocation && findNoteEventAtTick(song, selectedTrack, cursorLocation.tick);
+ const dragEvent = dragStart && findNoteEventAtTick(song, selectedTrack, dragStart.tick)
+
+ if (selection) {
+ gridHighlightStart = selection.startTick + selection.deltaTick;
+ gridHighlightEnd = selection.endTick + selection.deltaTick;
+ }
+ else if (!eraserActive && dragEvent && cursorLocation?.tick >= dragEvent.startTick) {
+ gridHighlightStart = dragEvent.startTick;
+ gridHighlightEnd = dragEvent.endTick;
+ }
+ else if (!eraserActive && isDragging && cursorLocation && dragStart) {
+ gridHighlightStart = Math.min(cursorLocation.tick, dragStart.tick);
+ gridHighlightEnd = Math.max(cursorLocation.tick, dragStart.tick);
+ }
+ else if (eventAtCursor) {
+ gridHighlightStart = eventAtCursor.startTick;
+ gridHighlightEnd = eventAtCursor.endTick;
+ }
+ else if (cursorLocation) {
+ gridHighlightStart = cursorLocation.tick;
+ gridHighlightEnd = cursorLocation.tick + gridTicks;
+ }
+
+ if (eventAtCursor && cursorPreviewLocation) {
+ cursorPreviewLocation = {
+ ...cursorPreviewLocation,
+ tick: eventAtCursor.startTick
+ }
+ }
+
+ if (trebleGridHighlightRef.current) {
+ trebleGridHighlightRef.current.setAttribute("x", `${gridHighlightStart !== undefined ? tickToX(song.ticksPerBeat, gridHighlightStart) : -1000}`);
+ trebleGridHighlightRef.current.setAttribute("width", `${gridHighlightEnd !== undefined ? tickToX(song.ticksPerBeat, gridHighlightEnd) - tickToX(song.ticksPerBeat, gridHighlightStart) : 0}`);
+ }
+
+ if (bassGridHighlightRef.current) {
+ bassGridHighlightRef.current.setAttribute("x", `${gridHighlightStart !== undefined ? tickToX(song.ticksPerBeat, gridHighlightStart) : -1000}`);
+ bassGridHighlightRef.current.setAttribute("width", `${gridHighlightEnd !== undefined ? tickToX(song.ticksPerBeat, gridHighlightEnd) - tickToX(song.ticksPerBeat, gridHighlightStart) : 0}`);
+ }
+
+ if (cursorPreviewLocationRef.current) {
+ if (cursorPreviewLocation) {
+ cursorPreviewLocationRef.current.setAttribute("x", `${tickToX(song.ticksPerBeat, cursorPreviewLocation.tick) - (NOTE_ICON_WIDTH / 2)}`);
+ cursorPreviewLocationRef.current.setAttribute("y", `${rowY(cursorPreviewLocation.row, cursorPreviewLocation.isBassClef) - (NOTE_ICON_WIDTH / 2)}`);
+ cursorPreviewLocationRef.current.style.display = "block";
+ }
+ else {
+ cursorPreviewLocationRef.current.style.display = "none";
+ }
+ }
+ };
+
+ setCursorLocation(cursorLocationRef.current);
+
workspaceRef.onpointerdown = ev => {
ev.preventDefault();
workspaceRef.focus();
@@ -46,6 +113,8 @@ export const Workspace = (props: WorkspaceProps) => {
workspaceRef.onpointermove = ev => {
const coord = coordinateToWorkspaceCoordinate(ev, workspaceRef, song, gridTicks);
+ const cursorLocation = cursorLocationRef.current;
+
if (cursorLocation && cursorLocation.exactTick === coord.exactTick && cursorLocation.row === coord.row) return;
const maxTick = song.beatsPerMeasure * song.ticksPerBeat * song.measures;
@@ -98,47 +167,12 @@ export const Workspace = (props: WorkspaceProps) => {
onWorkspaceClick(coord, ev.ctrlKey);
}
};
- }, [cursorLocation, dragStart, isDragging, onWorkspaceDrag, onWorkspaceDragEnd, onWorkspaceDragStart])
+ }, [dragStart, isDragging, onWorkspaceDrag, onWorkspaceDragEnd, onWorkspaceDragStart, song, selectedTrack, gridTicks, eraserActive, cursor]);
const handleWorkspaceRef = (ref: SVGSVGElement) => {
if (ref) workspaceRef = ref;
}
- let gridHighlightStart: number;
- let gridHighlightEnd: number;
-
- let cursorPreviewLocation = (isDragging || eraserActive) ? undefined : cursorLocation;
- const eventAtCursor = cursorLocation && findNoteEventAtTick(song, selectedTrack, cursorLocation.tick);
- const dragEvent = dragStart && findNoteEventAtTick(song, selectedTrack, dragStart.tick)
-
- if (selection) {
- gridHighlightStart = selection.startTick + selection.deltaTick;
- gridHighlightEnd = selection.endTick + selection.deltaTick;
- }
- else if (!eraserActive && dragEvent && cursorLocation?.tick >= dragEvent.startTick) {
- gridHighlightStart = dragEvent.startTick;
- gridHighlightEnd = dragEvent.endTick;
- }
- else if (!eraserActive && isDragging && cursorLocation && dragStart) {
- gridHighlightStart = Math.min(cursorLocation.tick, dragStart.tick);
- gridHighlightEnd = Math.max(cursorLocation.tick, dragStart.tick);
- }
- else if (eventAtCursor) {
- gridHighlightStart = eventAtCursor.startTick;
- gridHighlightEnd = eventAtCursor.endTick;
- }
- else if (cursorLocation) {
- gridHighlightStart = cursorLocation.tick;
- gridHighlightEnd = cursorLocation.tick + gridTicks;
- }
-
- if (eventAtCursor && cursorPreviewLocation) {
- cursorPreviewLocation = {
- ...cursorPreviewLocation,
- tick: eventAtCursor.startTick
- }
- }
-
const inactiveTracks = song.tracks.filter((t, i) => i !== selectedTrack);
const height = showBassClef ? WORKSPACE_HEIGHT * 2 : WORKSPACE_HEIGHT;
@@ -196,10 +230,19 @@ export const Workspace = (props: WorkspaceProps) => {
isBassClef={true}
gridTicks={gridTicks} />
}
-
+
+
+
{selection &&
{
+
+
+
+
+
}
diff --git a/webapp/src/components/musicEditor/playback.ts b/webapp/src/components/musicEditor/playback.ts
index 14e6f4ce6621..603fd2b08134 100644
--- a/webapp/src/components/musicEditor/playback.ts
+++ b/webapp/src/components/musicEditor/playback.ts
@@ -105,10 +105,12 @@ export class NoteTracker {
const oldNotes = this.activeNotes[i] || [];
const newNotes = newActiveNotes[i] || [];
+ const trackId = playbackSong.tracks[i]?.id;
+
for (const note of oldNotes) {
if (!newNotes.includes(note)) {
for (const listener of this.noteChangeListeners) {
- listener(i, note, false);
+ listener(trackId, note, false);
}
}
}
@@ -116,7 +118,7 @@ export class NoteTracker {
for (const note of newNotes) {
if (!oldNotes.includes(note)) {
for (const listener of this.noteChangeListeners) {
- listener(i, note, true);
+ listener(trackId, note, true);
}
}
}
diff --git a/webapp/src/components/pianoRoll/FieldEditor.tsx b/webapp/src/components/pianoRoll/FieldEditor.tsx
index 60abfdc1d6bc..173e20e191cd 100644
--- a/webapp/src/components/pianoRoll/FieldEditor.tsx
+++ b/webapp/src/components/pianoRoll/FieldEditor.tsx
@@ -1,6 +1,7 @@
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FieldEditorComponent } from "../../blocklyFieldView"
-import { FieldEditorParams, PianoRoll, PianoRollState } from "./PianoRoll"
+import { FieldEditorParams, PianoRoll, PianoRollModel } from "./PianoRoll"
+import { fromPXTSong, toPXTSong } from "./types";
interface Props {
handleRef: (e: FieldEditorComponent) => void;
@@ -14,72 +15,64 @@ interface DoneCallback {
export const PianoRollFieldEditor = (props: Props) => {
const { handleRef, fieldEditorParams } = props;
- const [asset, setAsset] = useState();
+ const model = useMemo(() => new PianoRollModel(), []);
+ const [initValue, setInitValue] = useState();
const [onDoneClicked, setOnDoneClicked] = useState(undefined);
- const [undoStack, setUndoStack] = useState([]);
- const [redoStack, setRedoStack] = useState([]);
- const [velocityEditorVisible, setVelocityEditorVisible] = useState(undefined);
- const [selectedTrack, setSelectedTrack] = useState(undefined);
-
- const resultRef = useRef();
useEffect(() => {
if (handleRef) {
+ let openedAsset = initValue;
handleRef({
init: (value: pxt.Song, close: () => void) => {
- setAsset(value);
+ openedAsset = value;
+
+ const songValue = fromPXTSong(value.song);
+ const name = value.meta?.displayName;
+
+ model.updateValue({
+ song: songValue,
+ assetName: name,
+ selectedTrackIndex: 0
+ }, { preserveUndo: false });
+ setInitValue(value);
setOnDoneClicked({ onDoneClicked: close });
},
getValue: () => {
- const result = {
- ...asset,
- song: resultRef.current?.asset
+ if (!model) return openedAsset;
+ const currentValue = model.getCurrentValue();
+ const pxtSong = toPXTSong(currentValue.song);
+
+ const result: pxt.Song = {
+ ...openedAsset,
+ song: pxtSong
};
- if (resultRef.current?.name) {
+ if (openedAsset?.meta || currentValue.assetName) {
result.meta = {
- ...(asset.meta || result.meta || {}),
- displayName: resultRef.current.name
+ ...(openedAsset?.meta || {}),
+ displayName: currentValue.assetName
}
}
return result;
},
getPersistentData: () => {
- return {
- undoStack: resultRef.current?.undoStack,
- redoStack: resultRef.current?.redoStack,
- velocityEditorVisible: resultRef.current?.velocityEditorVisible,
- selectedTrack: resultRef.current?.selectedTrackIndex
- }
+ return model?.getState();
},
restorePersistentData: (value: any) => {
- if (value) {
- setUndoStack(value.undoStack || []);
- setRedoStack(value.redoStack || []);
- setVelocityEditorVisible(value.velocityEditorVisible);
- setSelectedTrack(value.selectedTrack);
+ if (value && model) {
+ model.restoreState(value);
}
}
})
}
- }, [handleRef, asset])
-
- const onStateChange = useCallback((state: PianoRollState) => {
- resultRef.current = state;
- }, [])
+ }, [handleRef, model, initValue])
return (
)
diff --git a/webapp/src/components/pianoRoll/NoteEvent.tsx b/webapp/src/components/pianoRoll/NoteEvent.tsx
index 46e5c4f8f4a6..6a8667246530 100644
--- a/webapp/src/components/pianoRoll/NoteEvent.tsx
+++ b/webapp/src/components/pianoRoll/NoteEvent.tsx
@@ -27,7 +27,7 @@ export const NoteEventView = (props: Props) => {
data-tick={start}
data-note={note}
>
- {isDrumTrack ? undefined : getNoteName(note)}
+ {(isDrumTrack || duration <= 1) ? undefined : getNoteName(note)}
);
}
\ No newline at end of file
diff --git a/webapp/src/components/pianoRoll/PianoRoll.tsx b/webapp/src/components/pianoRoll/PianoRoll.tsx
index fdf2b2ab7d14..b37e434bcb21 100644
--- a/webapp/src/components/pianoRoll/PianoRoll.tsx
+++ b/webapp/src/components/pianoRoll/PianoRoll.tsx
@@ -2,7 +2,7 @@ import { PianoRollTheme, PianoRollThemeProvider, usePianoRollThemeContext } from
import { Workspace } from "./Workspace"
import { Sidebar } from "./Sidebar"
import { useEffect, useRef, useState, useCallback, useMemo } from "react"
-import { applyFloatingLayer, changeMeasures, changeOctaves, changeTimeSignature, changeTrackInstrument, deleteFloatingLayer, deleteSelection, fromPXTSong, getEmptySong, isDrumInstrument, moveFloatingLayer, moveSelection, newTrack, NoteEvent, NOTE_RANGES, selectionToFloatingLayer, Song, TIME_SIGNATURES, toPXTSong, Track, transposeFloatingLayer, transposeSelection, updateNoteEvent, updateNoteEvents, updateTrack, WorkspaceSelection } from "./types"
+import { applyFloatingLayer, changeMeasures, changeOctaves, changeTimeSignature, changeTrackInstrument, cloneSong, deleteFloatingLayer, deleteSelection, fromPXTSong, getEmptySong, isDrumInstrument, moveFloatingLayer, moveSelection, newTrack, NoteEvent, NOTE_RANGES, selectionToFloatingLayer, Song, TIME_SIGNATURES, toPXTSong, Track, transposeFloatingLayer, transposeSelection, updateNoteEvent, updateNoteEvents, updateTrack, WorkspaceSelection } from "./types"
import { Header } from "./Header"
import { DeleteTrackModal } from "./DeleteTrackModal"
import { DeleteErrorModal } from "./DeleteErrorModal"
@@ -18,16 +18,11 @@ import { classList } from "../../../../react-common/components/util"
import { getCopiedData, setCopiedData } from "./clipboard"
import { xToTick } from "./utils"
import { OctaveWarningModal } from "./OctaveWarningModal"
+import { AssetModel, useModelValue } from "../musicEditor/AssetModel"
interface PianoRollProps {
- onStateChanged?: (state: PianoRollState) => void;
- asset?: pxt.assets.music.Song;
- undoStack?: StateSnapshot[];
- redoStack?: StateSnapshot[];
- selectedTrackIndex?: number;
- velocityEditorVisible?: boolean;
+ model: PianoRollModel;
showEditControls?: boolean;
- name?: string;
onDoneClicked?: () => void;
fieldEditorParams?: FieldEditorParams;
}
@@ -48,15 +43,6 @@ export const PianoRoll = (props: PianoRollProps) => {
)
}
-export interface PianoRollState {
- undoStack: StateSnapshot[];
- redoStack: StateSnapshot[];
- asset: pxt.assets.music.Song;
- selectedTrackIndex: number;
- velocityEditorVisible: boolean;
- name?: string;
-}
-
export interface FieldEditorParams {
hideHeader?: boolean;
maxPolyphony?: number;
@@ -70,77 +56,50 @@ export interface FieldEditorParams {
interface StateSnapshot {
song: Song;
selectedTrackIndex: number;
+ assetName?: string;
+}
+
+interface ExtraState {
+ velocityEditorVisible: boolean;
+}
+
+
+export class PianoRollModel extends AssetModel