From cc88c141e8bec1a8f331659094e7e30d0025ea10 Mon Sep 17 00:00:00 2001 From: hlorenzi Date: Fri, 5 Feb 2021 22:33:20 -0300 Subject: [PATCH] add context menu; add convert notes to chords command --- README.md | 1 + src/project/_global.ts | 9 +- src/project/root.ts | 21 +++ src/theory/chord.ts | 55 ++++++ src/timeline/Element.tsx | 12 ++ src/timeline/action_convertNotesToChords.ts | 180 ++++++++++++++++++++ src/timeline/index.ts | 3 +- src/timeline/state.ts | 26 +-- src/timeline/state_keyDown.ts | 6 +- src/timeline/state_mouseUp.tsx | 108 ++++++++---- 10 files changed, 361 insertions(+), 60 deletions(-) create mode 100644 src/timeline/action_convertNotesToChords.ts diff --git a/README.md b/README.md index 14c63d2..b4fe2b3 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Several soundfonts are readily available from an online repository. * Use the middle or right mouse buttons to pan. * Hold A to draw elements with the mouse. * Right-click on an element to change its properties. + * Do a long right-click for a context menu with more commands. * Double-click on a note block to edit its notes. * Click on "Project Root" on the breadcrumb bar to exit note editing mode. diff --git a/src/project/_global.ts b/src/project/_global.ts index 45a17bc..34c4f80 100644 --- a/src/project/_global.ts +++ b/src/project/_global.ts @@ -1,4 +1,5 @@ import * as Project from "./index" +import * as Timeline from "../timeline" import * as GlobalObservable from "../util/globalObservable" @@ -71,7 +72,7 @@ export function setNew() clearUndoStack() notifyObservers() - window.dispatchEvent(new Event("timelineReset")) + Timeline.sendEventReset() } @@ -82,7 +83,7 @@ export function open(openedProject: Project.Root) clearUndoStack() notifyObservers() - window.dispatchEvent(new Event("timelineReset")) + Timeline.sendEventReset() } @@ -142,7 +143,7 @@ export function undo() saveToLocalStorageWithCooldown(global.project) notifyObservers() - window.dispatchEvent(new Event("timelineRefresh")) + Timeline.sendEventRefresh() } @@ -157,7 +158,7 @@ export function redo() saveToLocalStorageWithCooldown(global.project) notifyObservers() - window.dispatchEvent(new Event("timelineRefresh")) + Timeline.sendEventRefresh() } diff --git a/src/project/root.ts b/src/project/root.ts index 3d41a11..e6854ee 100644 --- a/src/project/root.ts +++ b/src/project/root.ts @@ -511,6 +511,27 @@ export function getRelativeRange(project: Root, parentId: Project.ID, range: Ran } +export function getRangeForElems(project: Root, elemIds: Iterable): Range | null +{ + let range: Range | null = null + + for (const id of elemIds) + { + const elem = project.elems.get(id) as Project.Element + if (!elem) + continue + + if (elem.type == "track") + continue + + const absRange = Project.getAbsoluteRange(project, elem.parentId, elem.range) + range = Range.merge(range, absRange) + } + + return range +} + + export function getMillisecondsAt(project: Root, time: Rational): number { const measuresPerSecond = (project.baseBpm / 4 / 60) diff --git a/src/theory/chord.ts b/src/theory/chord.ts index b015c07..016324c 100644 --- a/src/theory/chord.ts +++ b/src/theory/chord.ts @@ -50,6 +50,14 @@ export const chordKinds: ChordMetadata[] = ] +export interface ChordSuggestion +{ + chord: Chord + matches: number + misses: number +} + + export default class Chord { static kinds: ChordMetadata[] = chordKinds @@ -90,6 +98,53 @@ export default class Chord } + static suggestChordsForPitches(pitches: number[]): ChordSuggestion[] + { + const suggestions: ChordSuggestion[] = [] + + for (let k = 0; k < chordKinds.length; k++) + { + const kind = chordKinds[k] + + for (let root = 0; root < pitches.length; root++) + { + const rootPitch = pitches[root] + const matches = new Set() + let misses = 0 + + for (let i = 0; i < pitches.length; i++) + { + const pitch = pitches[(root + i) % pitches.length] + const relPitch = MathUtils.mod(pitch - rootPitch, 12) + + const kindMatch = kind.pitches.findIndex(p => p === relPitch) + if (kindMatch >= 0) + matches.add(kindMatch) + else + misses++ + } + + suggestions.push({ + chord: new Chord(MathUtils.mod(rootPitch, 12), k, 0, []), + matches: matches.size, + misses, + }) + } + } + + suggestions.sort((a, b) => + { + if (a.misses != b.misses) + return a.misses - b.misses + + return b.matches - a.matches + }) + + //console.log("suggestions", pitches, suggestions) + return suggestions.slice(0, 10) + } + + get kindId(): string { return chordKinds[this.kind].id diff --git a/src/timeline/Element.tsx b/src/timeline/Element.tsx index 7f7e9b3..3f29d1c 100644 --- a/src/timeline/Element.tsx +++ b/src/timeline/Element.tsx @@ -393,4 +393,16 @@ export function TimelineElement(props: { state?: RefState }) editorState.ref.current.tracks, editorState.ref.current.trackScroll, ]) +} + + +export function sendEventRefresh() +{ + window.dispatchEvent(new Event("timelineRefresh")) +} + + +export function sendEventReset() +{ + window.dispatchEvent(new Event("timelineReset")) } \ No newline at end of file diff --git a/src/timeline/action_convertNotesToChords.ts b/src/timeline/action_convertNotesToChords.ts new file mode 100644 index 0000000..e997f64 --- /dev/null +++ b/src/timeline/action_convertNotesToChords.ts @@ -0,0 +1,180 @@ +import * as Project from "../project" +import * as Timeline from "./index" +import * as Theory from "../theory" +import * as MathUtils from "../util/mathUtils" +import Rational from "../util/rational" +import Range from "../util/range" + + +export function convertNotesToChords(data: Timeline.WorkData, elemIds: Iterable) +{ + const elems: Project.Note[] = [] + for (const elemId of elemIds) + { + const note = Project.getElem(Project.global.project, elemId, "note") + if (note) + elems.push(note) + + const noteBlock = Project.getElem(Project.global.project, elemId, "noteBlock") + if (noteBlock) + { + const list = Project.global.project.lists.get(noteBlock.id) + if (list) + { + for (const elem of list.iterAll()) + { + if (elem.type == "note") + elems.push(elem) + } + } + } + } + + if (elems.length == 0) + return + + const trackId = Project.global.project.chordTrackId + + Timeline.selectionClear(data) + + const groups = calculateGroups(data, elems) + let chordsToAdd: ChordToAdd[] = [] + for (const group of groups) + { + const midiPitches = group.elems.map(elem => elem.midiPitch) + const chordSuggestions = Theory.Chord.suggestChordsForPitches(midiPitches) + + if (chordSuggestions.length >= 1) + { + chordsToAdd.push({ + chord: chordSuggestions[0].chord, + range: group.range, + }) + } + } + + chordsToAdd = coallesceNeighboringChordsToAdd(chordsToAdd) + + for (const chordToAdd of chordsToAdd) +{ + const projChord = Project.makeChord( + trackId, + chordToAdd.range, + chordToAdd.chord) + + const id = Project.global.project.nextId + Project.global.project = Project.upsertElement(Project.global.project, projChord) + + Timeline.selectionAdd(data, id) + } + + Project.global.project = Project.withRefreshedRange(Project.global.project) + + data.state.cursor.visible = false + Timeline.selectionRemoveConflictingBehind(data) +} + + +interface ChordToAdd +{ + chord: Theory.Chord + range: Range +} + + +function coallesceNeighboringChordsToAdd(chordsToAdd: ChordToAdd[]): ChordToAdd[] +{ + const newChordsToAdd: ChordToAdd[] = [] + + let prev = -1 + for (let i = 0; i < chordsToAdd.length; i++) + { + if (prev < 0) + { + prev = newChordsToAdd.length + newChordsToAdd.push({ ...chordsToAdd[i] }) + } + else + { + const prevChord = newChordsToAdd[prev].chord + const nextChord = chordsToAdd[i].chord + + if (prevChord.rootChroma == nextChord.rootChroma && + prevChord.kind == nextChord.kind) + { + newChordsToAdd[prev].range = newChordsToAdd[prev].range.merge(chordsToAdd[i].range) + } + else + { + // Fill holes? + //newChordsToAdd[prev].range = new Range(newChordsToAdd[prev].range.start, chordsToAdd[i].range.start) + + prev = newChordsToAdd.length + newChordsToAdd.push({ ...chordsToAdd[i] }) + } + } + } + + return newChordsToAdd +} + + +interface Group +{ + elems: Project.Note[] + range: Range +} + + +function calculateGroups(data: Timeline.WorkData, elems: Project.Note[]): Group[] +{ + const minTime = elems.reduce((accum, elem) => + { + const start = Project.getAbsoluteTime(Project.global.project, elem.parentId, elem.range.start) + return start.min(accum) + }, + null)! + + const maxTime = elems.reduce((accum, elem) => + { + const end = Project.getAbsoluteTime(Project.global.project, elem.parentId, elem.range.end) + return end.max(accum) + }, + null)! + + const groups: Group[] = [] + + let curTime = minTime + while (curTime.compare(maxTime) < 0) + { + const nextTime = elems.reduce((accum, elem) => + { + const range = Project.getAbsoluteRange(Project.global.project, elem.parentId, elem.range) + if (range.start.compare(curTime) > 0) + return range.start.min(accum) + + if (range.end.compare(curTime) > 0) + return range.end.min(accum) + + return accum + }, + null!) || maxTime + + const groupRange = new Range(curTime, nextTime) + + const groupElems = elems.filter(elem => + { + const range = Project.getAbsoluteRange(Project.global.project, elem.parentId, elem.range) + return groupRange.overlapsRange(range) + }) + + groups.push({ + elems: groupElems, + range: groupRange, + }) + + curTime = nextTime + } + + return groups +} \ No newline at end of file diff --git a/src/timeline/index.ts b/src/timeline/index.ts index 924bf5f..ecf0082 100644 --- a/src/timeline/index.ts +++ b/src/timeline/index.ts @@ -15,4 +15,5 @@ export * from "./trackChords" export * from "./trackNotes" export * from "./trackNoteVolumes" export * from "./trackNoteVelocities" -export * from "./Element" \ No newline at end of file +export * from "./Element" +export * from "./action_convertNotesToChords" \ No newline at end of file diff --git a/src/timeline/state.ts b/src/timeline/state.ts index bfc4a31..ee389b6 100644 --- a/src/timeline/state.ts +++ b/src/timeline/state.ts @@ -632,22 +632,7 @@ export function selectionClear(data: WorkData) export function selectionRange(data: WorkData): Range | null { - let range: Range | null = null - - for (const id of data.state.selection) - { - const elem = Project.global.project.elems.get(id) as Project.Element - if (!elem) - continue - - if (elem.type == "track") - continue - - const absRange = Project.getAbsoluteRange(Project.global.project, elem.parentId, elem.range) - range = Range.merge(range, absRange) - } - - return range + return Project.getRangeForElems(Project.global.project, data.state.selection) } @@ -697,11 +682,12 @@ export function selectionAddAtCursor(data: WorkData) } -export function selectionDelete(data: WorkData) +export function deleteElems(data: WorkData, elemIds: Iterable) { - const range = selectionRange(data) || new Range(data.state.cursor.time1, data.state.cursor.time1) + const range = Project.getRangeForElems(Project.global.project, elemIds) || + new Range(data.state.cursor.time1, data.state.cursor.time1) - for (const id of data.state.selection) + for (const id of elemIds) { const elem = Project.global.project.elems.get(id) if (!elem) @@ -714,7 +700,7 @@ export function selectionDelete(data: WorkData) Project.global.project = Project.upsertElement(Project.global.project, removeElem) } - for (const id of data.state.selection) + for (const id of elemIds) { const track = Project.global.project.elems.get(id) if (!track) diff --git a/src/timeline/state_keyDown.ts b/src/timeline/state_keyDown.ts index 0121a0a..ac7fda1 100644 --- a/src/timeline/state_keyDown.ts +++ b/src/timeline/state_keyDown.ts @@ -62,7 +62,7 @@ export function keyDown(data: Timeline.WorkData, key: string) if (data.state.keysDown.has("control")) { Timeline.selectionCopy(data) - Timeline.selectionDelete(data) + Timeline.deleteElems(data, data.state.selection) Project.splitUndoPoint() Project.addUndoPoint("cut") } @@ -232,7 +232,7 @@ function handleEnter(data: Timeline.WorkData) function handleDelete(data: Timeline.WorkData) { - Timeline.selectionDelete(data) + Timeline.deleteElems(data, data.state.selection) } @@ -240,7 +240,7 @@ function handleBackspace(data: Timeline.WorkData) { if (!data.state.cursor.visible) { - Timeline.selectionDelete(data) + Timeline.deleteElems(data, data.state.selection) return } diff --git a/src/timeline/state_mouseUp.tsx b/src/timeline/state_mouseUp.tsx index dc05c65..876dc08 100644 --- a/src/timeline/state_mouseUp.tsx +++ b/src/timeline/state_mouseUp.tsx @@ -89,43 +89,87 @@ function handleContextMenu(data: Timeline.WorkData) if (data.state.hover) { - //data.state.tracks[data.state.mouse.point.trackIndex].contextMenu(data, data.state.hover!.id) - - data.dockable.ref.current.createFloatingEphemeral( - Windows.Inspector, - { elemIds: [...data.state.selection] }, - 1, 1) + const clickDurationMs = (new Date().getTime()) - data.state.mouse.downDate.getTime() + + const fnOpenProperties = () => + { + data.dockable.ref.current.createFloatingEphemeral( + Windows.Inspector, + { elemIds: [...data.state.selection] }, + 1, 1) + + data.dockable.commit() + } + + if (clickDurationMs < 150) + { + fnOpenProperties() + } + else + { + data.popup.ref.current.elem = () => + { + return + { makeContextMenu(data, fnOpenProperties) } + + } + data.popup.ref.current.rect = new Rect( + data.state.renderRect.x + data.state.mouse.point.pos.x + 2, + data.state.renderRect.y + data.state.mouse.point.pos.y + 2, + 0, 0) + data.popup.commit() + } } +} + - /*const elems: JSX.Element[] = [] - const trackCtxMenu = data.state.tracks[data.state.mouse.point.trackIndex].contextMenu(data) - if (trackCtxMenu) +function makeContextMenu(data: Timeline.WorkData, fnOpenProperties: () => void): JSX.Element[] +{ + const menuItems: JSX.Element[] = [] + + menuItems.push() + + menuItems.push( + { + Timeline.deleteElems(data, data.state.selection) + Timeline.sendEventRefresh() + Project.splitUndoPoint() + Project.addUndoPoint("menuDelete") + }}/>) + + + const hasElemType = (type: Project.Element["type"]) => { - elems.push(trackCtxMenu) - elems.push() + for (const id of data.state.selection) + { + const elem = Project.global.project.elems.get(id) + if (elem && elem.type === type) + return true + } + + return false } - elems.push( - <> - - - - - - - - - ) - - data.popup.ref.current.elem = () => + + if (hasElemType("note") || hasElemType("noteBlock")) { - return - { elems } - + menuItems.push() + + menuItems.push( + { + Timeline.convertNotesToChords(data, data.state.selection) + Timeline.sendEventRefresh() + Project.splitUndoPoint() + Project.addUndoPoint("menuConvertNotesToChords") + }}/>) } - data.popup.ref.current.rect = new Rect( - data.state.renderRect.x + data.state.mouse.point.pos.x + 2, - data.state.renderRect.y + data.state.mouse.point.pos.y + 2, - 0, 0) - data.popup.commit()*/ + + + return menuItems } \ No newline at end of file