migrated to vue wtf
This commit is contained in:
1744
package-lock.json
generated
1744
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,12 @@
|
|||||||
"author": "Daniel Ledda",
|
"author": "Daniel Ledda",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@djledda/ladder": "^1.1.0"
|
"@djledda/ladder": "^1.1.0",
|
||||||
|
"@vitejs/plugin-vue": "^4.0.0",
|
||||||
|
"@vue/tsconfig": "^0.1.3",
|
||||||
|
"pinia": "^2.0.32",
|
||||||
|
"sass": "^1.58.3",
|
||||||
|
"vue": "^3.2.47"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^17.0.40",
|
"@types/node": "^17.0.40",
|
||||||
@@ -24,6 +29,6 @@
|
|||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^8.16.0",
|
"eslint": "^8.16.0",
|
||||||
"typescript": "^4.9.4",
|
"typescript": "^4.9.4",
|
||||||
"vite": "^3.2.0"
|
"vite": "^4.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,31 @@
|
|||||||
import { ISubscriber, Publisher } from "@djledda/ladder";
|
import { inject, type InjectionKey, ref } from 'vue';
|
||||||
import { TrackUnitStickingType, TrackUnitType } from "./TrackUnit";
|
|
||||||
|
|
||||||
export type UITool =
|
export type UITool =
|
||||||
| "track-unit-type"
|
| "track-unit-type"
|
||||||
| "eraser"
|
| "eraser"
|
||||||
| "sticking";
|
| "sticking";
|
||||||
|
|
||||||
export type AppStateEvent = "appstate-tool-select";
|
export function createAppStateStore() {
|
||||||
|
const selectedTool = ref<UITool>('track-unit-type');
|
||||||
export default class AppState {
|
const activeTrackUnitType = ref(0);
|
||||||
private publisher = new Publisher<AppStateEvent, AppState>(this);
|
const activeStickingType = ref(1);
|
||||||
selectedTool: UITool = "track-unit-type";
|
const selectingUnits = ref(false);
|
||||||
activeTrackUnitType: TrackUnitType = "Normal";
|
const deselectingUnits = ref(false);
|
||||||
activeStickingType: TrackUnitStickingType = "lh";
|
|
||||||
|
return {
|
||||||
constructor() {}
|
selectingUnits,
|
||||||
|
deselectingUnits,
|
||||||
addSubscriber(subscriber: ISubscriber<AppStateEvent>, subscribeTo: Parameters<Publisher<AppStateEvent, AppState>["addSubscriber"]>[1]) {
|
selectedTool,
|
||||||
this.publisher.addSubscriber(subscriber, subscribeTo);
|
activeTrackUnitType,
|
||||||
}
|
activeStickingType,
|
||||||
|
};
|
||||||
selectStickingTypePaint(stickingType: TrackUnitStickingType) {
|
}
|
||||||
this.activeStickingType = stickingType;
|
|
||||||
this.publisher.notifySubs("appstate-tool-select");
|
|
||||||
}
|
export type AppStateStore = ReturnType<typeof createAppStateStore>;
|
||||||
|
|
||||||
selectTrackUnitTypePaint(trackUnitType: TrackUnitType) {
|
export const AppStateStoreKey = Symbol('AppStateStore') as InjectionKey<AppStateStore>;
|
||||||
this.activeTrackUnitType = trackUnitType;
|
|
||||||
this.publisher.notifySubs("appstate-tool-select");
|
export function useAppStateStore(): AppStateStore {
|
||||||
}
|
return inject(AppStateStoreKey, createAppStateStore, true);
|
||||||
|
|
||||||
selectTool(tool: UITool) {
|
|
||||||
this.selectedTool = tool;
|
|
||||||
this.publisher.notifySubs("appstate-tool-select");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
465
src/Beat.ts
465
src/Beat.ts
@@ -1,6 +1,6 @@
|
|||||||
import Track, { TrackEvents, TrackInitOptions } from "@/Track";
|
import { deserialise as deserialiseTrack, type TrackInitOptions, createTrack, isValidTimeSigRange, type Track } from "@/Track";
|
||||||
import { greatestCommonDivisor, isPosInt } from "@/utils";
|
import { greatestCommonDivisor, isPosInt } from "@/utils";
|
||||||
import { Capsule, ICapsule, IPublisher, ISubscriber, Publisher } from "@djledda/ladder";
|
import { watch, computed, effectScope, ref, shallowRef, triggerRef } from "vue";
|
||||||
|
|
||||||
type BeatGroupInitOptions = {
|
type BeatGroupInitOptions = {
|
||||||
barCount: number;
|
barCount: number;
|
||||||
@@ -23,337 +23,182 @@ export type BeatSerial = {
|
|||||||
name: string,
|
name: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const enum BeatEvents {
|
function isBeatSerial(serial: Record<string, unknown>): serial is BeatSerial {
|
||||||
TrackOrderChanged="be-0",
|
return Array.isArray(serial.tracks) &&
|
||||||
TrackListChanged="be-1",
|
typeof serial.barCount === "number" &&
|
||||||
BarCountChanged="be-2",
|
typeof serial.timeSigUp === "number" &&
|
||||||
TimeSigUpChanged="be-3",
|
typeof serial.globalLoopLength === "number" &&
|
||||||
AutoBeatSettingsChanged="be-4",
|
typeof serial.globalIsLooping === "boolean" &&
|
||||||
LockingChanged="be-5",
|
typeof serial.useAutoBeatLength === "boolean" &&
|
||||||
GlobalLoopLengthChanged="be-5",
|
typeof serial.barSettingsLocked === "boolean";
|
||||||
GlobalDisplayTypeChanged="be-6",
|
|
||||||
DeepChange="be-7",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EventTypeSubscriptions = [
|
export function deserialise(serial: {}) {
|
||||||
TrackEvents.LoopLengthChanged,
|
if (!isBeatSerial(serial)) {
|
||||||
TrackEvents.DisplayTypeChanged,
|
throw new Error("Not a valid beat serial");
|
||||||
TrackEvents.WantsRemoval,
|
|
||||||
TrackEvents.DeepChange,
|
|
||||||
TrackEvents.Baked,
|
|
||||||
];
|
|
||||||
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
|
|
||||||
|
|
||||||
export default class Beat implements IPublisher<BeatEvents>, ISubscriber<EventTypeSubscriptions> {
|
|
||||||
private static globalCounter = 0;
|
|
||||||
private tracks: Track[] = [];
|
|
||||||
private publisher: Publisher<BeatEvents, Beat> = new Publisher<BeatEvents, Beat>(this);
|
|
||||||
private barCount: number;
|
|
||||||
private timeSigUp: number;
|
|
||||||
private globalLoopLength: number;
|
|
||||||
private globalIsLooping: boolean;
|
|
||||||
private useAutoBeatLength: boolean;
|
|
||||||
private barSettingsLocked = false;
|
|
||||||
private name: ICapsule<string>;
|
|
||||||
|
|
||||||
constructor(options?: BeatGroupInitOptions) {
|
|
||||||
Beat.globalCounter++;
|
|
||||||
if (options?.name) {
|
|
||||||
this.name = Capsule.new<string>(options.name);
|
|
||||||
} else {
|
|
||||||
this.name = Capsule.new<string>(`Pattern ${Beat.globalCounter}`);
|
|
||||||
}
|
|
||||||
if (options?.tracks) {
|
|
||||||
for (const trackOptions of options.tracks) {
|
|
||||||
this.addTrack(trackOptions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.barCount = options?.barCount ?? 4;
|
|
||||||
this.timeSigUp = options?.timeSigUp ?? 4;
|
|
||||||
this.globalLoopLength = options?.loopLength ?? this.timeSigUp;
|
|
||||||
this.globalIsLooping = options?.isLooping ?? false;
|
|
||||||
this.useAutoBeatLength = options?.useAutoBeatLength ?? false;
|
|
||||||
}
|
}
|
||||||
|
const newBeat = createBeat({
|
||||||
|
loopLength: serial.globalLoopLength,
|
||||||
|
barCount: serial.barCount,
|
||||||
|
isLooping: serial.globalIsLooping,
|
||||||
|
name: serial.name,
|
||||||
|
timeSigUp: serial.timeSigUp,
|
||||||
|
useAutoBeatLength: serial.useAutoBeatLength,
|
||||||
|
});
|
||||||
|
serial.tracks.forEach(trackSerial => {
|
||||||
|
const track = deserialiseTrack(trackSerial);
|
||||||
|
if (track) newBeat.tracks.value.push(track);
|
||||||
|
});
|
||||||
|
return newBeat;
|
||||||
|
}
|
||||||
|
|
||||||
static deserialise(serial: any): Beat {
|
export function createBeat(opts: BeatGroupInitOptions) {
|
||||||
if (!Beat.isBeatSerial(serial)) {
|
const scope = effectScope();
|
||||||
throw new Error("Not a valid beat serial");
|
return scope.run(() => {
|
||||||
}
|
const tracks = shallowRef<Track[]>([]);
|
||||||
const newBeat = new Beat({
|
const barCountInternal = ref<number>(opts.barCount ?? 4);
|
||||||
loopLength: serial.globalLoopLength,
|
const barCount = computed({
|
||||||
barCount: serial.barCount,
|
get() {
|
||||||
isLooping: serial.globalIsLooping,
|
if (useAutoBeatLength.value) {
|
||||||
name: serial.name,
|
const loopLengths = [timeSigUp.value];
|
||||||
timeSigUp: serial.timeSigUp,
|
for (const track of tracks.value) {
|
||||||
useAutoBeatLength: serial.useAutoBeatLength,
|
if (track.looping.value) {
|
||||||
});
|
const loopLength = track.loopLength.value;
|
||||||
serial.tracks.forEach(trackSerial => newBeat.addTrack(Track.deserialise(trackSerial)));
|
if (loopLengths.indexOf(loopLength) === -1) {
|
||||||
return newBeat;
|
loopLengths.push(loopLength);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
notify(publisher: unknown, event: EventTypeSubscriptions): void {
|
}
|
||||||
switch (event) {
|
const smallestLoopLength = loopLengths.reduce((prev, curr) => (prev * curr) / greatestCommonDivisor(prev, curr), 1);
|
||||||
case TrackEvents.LoopLengthChanged:
|
return smallestLoopLength / timeSigUp.value;
|
||||||
case TrackEvents.DisplayTypeChanged:
|
|
||||||
this.autoBeatLength();
|
|
||||||
break;
|
|
||||||
case TrackEvents.WantsRemoval:
|
|
||||||
this.removeTrack((publisher as Track).getKey());
|
|
||||||
break;
|
|
||||||
case TrackEvents.Baked:
|
|
||||||
this.setIsUsingAutoBeatLength(false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
this.publisher.notifySubs(BeatEvents.DeepChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
addSubscriber(subscriber: ISubscriber<BeatEvents>, eventType: BeatEvents | Readonly<BeatEvents[]>): { unbind: () => void } {
|
|
||||||
return this.publisher.addSubscriber(subscriber, eventType);
|
|
||||||
}
|
|
||||||
|
|
||||||
private setBarCountInternal(barCount: number): void {
|
|
||||||
if (!isPosInt(barCount)) {
|
|
||||||
barCount = this.barCount;
|
|
||||||
}
|
|
||||||
this.barCount = barCount;
|
|
||||||
for (const track of this.tracks) {
|
|
||||||
track.setBarCount(barCount);
|
|
||||||
}
|
|
||||||
this.publisher.notifySubs(BeatEvents.BarCountChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
setBarCount(barCount: number): void {
|
|
||||||
if (!this.barSettingsLocked) {
|
|
||||||
this.setBarCountInternal(barCount);
|
|
||||||
} else {
|
|
||||||
this.setBarCountInternal(this.barCount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getBarCount(): number {
|
|
||||||
return this.barCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoopLength(loopLength: number): void {
|
|
||||||
if (!isPosInt(loopLength)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.globalLoopLength = loopLength;
|
|
||||||
for (const track of this.tracks) {
|
|
||||||
track.setLoopLength(loopLength);
|
|
||||||
}
|
|
||||||
this.publisher.notifySubs(BeatEvents.GlobalLoopLengthChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
getLoopLength(): number {
|
|
||||||
return this.globalLoopLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLooping(isLooping: boolean): void {
|
|
||||||
this.globalIsLooping = isLooping;
|
|
||||||
for (const track of this.tracks) {
|
|
||||||
track.setLooping(isLooping);
|
|
||||||
}
|
|
||||||
this.publisher.notifySubs(BeatEvents.GlobalDisplayTypeChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
isLooping(): boolean {
|
|
||||||
return this.globalIsLooping;
|
|
||||||
}
|
|
||||||
|
|
||||||
private findSmallestLoopLength(): number {
|
|
||||||
const loopLengths = [this.timeSigUp];
|
|
||||||
for (const track of this.tracks) {
|
|
||||||
if (track.isLooping()) {
|
|
||||||
const loopLength = track.getLoopLength();
|
|
||||||
if (loopLengths.indexOf(loopLength) === -1) {
|
|
||||||
loopLengths.push(loopLength);
|
|
||||||
}
|
}
|
||||||
|
return barCountInternal.value;
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
if (barSettingsLocked.value || !isPosInt(val)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
barCountInternal.value = val;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const timeSigUp = ref<number>(opts.timeSigUp ?? 4);
|
||||||
|
const globalLoopLengthInternal = ref<number>(opts.loopLength ?? timeSigUp.value);
|
||||||
|
const globalLoopLength = computed({
|
||||||
|
get() {
|
||||||
|
return globalLoopLengthInternal.value;
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
if (!isPosInt(val)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
globalLoopLengthInternal.value = val;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const globalIsLooping = ref<boolean>(opts.isLooping ?? false);
|
||||||
|
const useAutoBeatLength = ref(opts.useAutoBeatLength ?? false);
|
||||||
|
const barSettingsLocked = computed(() => useAutoBeatLength.value);
|
||||||
|
const name = ref(opts.name ?? 'Beat');
|
||||||
|
|
||||||
|
function setTimeSigUp(timeSigVal: number): void {
|
||||||
|
if (!isValidTimeSigRange(timeSigVal)) {
|
||||||
|
timeSigVal = timeSigUp.value;
|
||||||
|
}
|
||||||
|
timeSigUp.value = timeSigVal;
|
||||||
|
for (const track of tracks.value) {
|
||||||
|
track.timeSigUp.value = timeSigVal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (loopLengths.length === 1) {
|
|
||||||
loopLengths.push(1);
|
function getTrackByIndex(trackIndex: number) {
|
||||||
|
const track = tracks.value[trackIndex];
|
||||||
|
if (!track) {
|
||||||
|
throw new Error(`Could not find the track with index: ${trackIndex}`);
|
||||||
|
}
|
||||||
|
return track;
|
||||||
}
|
}
|
||||||
return loopLengths.reduce((prev, curr) => (prev * curr) / greatestCommonDivisor(prev, curr));
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeSigUp(timeSigUp: number): void {
|
function swapTracksByIndices(trackIndex1: number, trackIndex2: number): void {
|
||||||
if (!Track.isValidTimeSigRange(timeSigUp)) {
|
const track1 = getTrackByIndex(trackIndex1);
|
||||||
timeSigUp = this.timeSigUp;
|
const track2 = getTrackByIndex(trackIndex2);
|
||||||
|
tracks.value[trackIndex1] = track2;
|
||||||
|
tracks.value[trackIndex2] = track1;
|
||||||
}
|
}
|
||||||
this.timeSigUp = timeSigUp;
|
|
||||||
for (const track of this.tracks) {
|
|
||||||
track.setTimeSignature({ up: timeSigUp });
|
|
||||||
}
|
|
||||||
this.autoBeatLength();
|
|
||||||
this.publisher.notifySubs(BeatEvents.TimeSigUpChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
getTimeSigUp(): number {
|
function addTrack(options?: TrackInitOptions): Track | null {
|
||||||
return this.timeSigUp;
|
let newTrack: Track | null;
|
||||||
}
|
options = {
|
||||||
|
bars: barCount.value,
|
||||||
getTrackByKey(trackKey: string): Track {
|
isLooping: globalIsLooping.value,
|
||||||
const foundTrack = this.tracks.find(track => track.getKey() === trackKey);
|
loopLength: globalLoopLength.value,
|
||||||
if (typeof foundTrack === "undefined") {
|
...options,
|
||||||
throw new Error(`Could not find the track with key: ${trackKey}`);
|
|
||||||
}
|
|
||||||
return foundTrack;
|
|
||||||
}
|
|
||||||
|
|
||||||
getTrackByIndex(trackIndex: number): Track {
|
|
||||||
if (!this.tracks[trackIndex]) {
|
|
||||||
throw new Error(`Could not find the track with index: ${trackIndex}`);
|
|
||||||
}
|
|
||||||
return this.tracks[trackIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
getTrackCount(): number {
|
|
||||||
return this.tracks.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
getTrackKeys(): string[] {
|
|
||||||
return this.tracks.map(track => track.getKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
swapTracksByIndices(trackIndex1: number, trackIndex2: number): void {
|
|
||||||
const track1 = this.getTrackByIndex(trackIndex1);
|
|
||||||
const track2 = this.getTrackByIndex(trackIndex2);
|
|
||||||
this.tracks[trackIndex1] = track2;
|
|
||||||
this.tracks[trackIndex2] = track1;
|
|
||||||
this.publisher.notifySubs(BeatEvents.TrackOrderChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
moveTrackBack(trackKey: string): void {
|
|
||||||
const index = this.tracks.indexOf(this.getTrackByKey(trackKey));
|
|
||||||
if (typeof index !== "undefined" && index > 0) {
|
|
||||||
this.swapTracksByIndices(index, index - 1);
|
|
||||||
}
|
|
||||||
this.publisher.notifySubs(BeatEvents.TrackOrderChanged);
|
|
||||||
this.publisher.notifySubs(BeatEvents.TrackListChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
moveTrackForward(trackKey: string): void {
|
|
||||||
const index = this.tracks.indexOf(this.getTrackByKey(trackKey));
|
|
||||||
if (typeof index !== "undefined" && index < this.getTrackCount()) {
|
|
||||||
this.swapTracksByIndices(index, index + 1);
|
|
||||||
}
|
|
||||||
this.publisher.notifySubs(BeatEvents.TrackOrderChanged);
|
|
||||||
this.publisher.notifySubs(BeatEvents.TrackListChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
canMoveTrackBack(trackKey: string): boolean {
|
|
||||||
return this.tracks.indexOf(this.getTrackByKey(trackKey)) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
canMoveTrackForward(trackKey: string): boolean {
|
|
||||||
return this.tracks.indexOf(this.getTrackByKey(trackKey)) < this.tracks.length - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
addTrack(track: Track): void;
|
|
||||||
addTrack(options?: TrackInitOptions): Track;
|
|
||||||
addTrack(optionsOrTrack?: Track | TrackInitOptions): Track | void {
|
|
||||||
let newTrack: Track;
|
|
||||||
if (optionsOrTrack instanceof Track) {
|
|
||||||
newTrack = optionsOrTrack;
|
|
||||||
} else {
|
|
||||||
optionsOrTrack = {
|
|
||||||
timeSig: {
|
timeSig: {
|
||||||
up: this.timeSigUp,
|
up: timeSigUp.value,
|
||||||
down: 4,
|
down: 4,
|
||||||
|
...options?.timeSig ?? {},
|
||||||
},
|
},
|
||||||
bars: this.barCount,
|
|
||||||
isLooping: this.globalIsLooping,
|
|
||||||
loopLength: this.globalLoopLength,
|
|
||||||
...optionsOrTrack
|
|
||||||
};
|
};
|
||||||
newTrack = new Track(optionsOrTrack);
|
newTrack = createTrack(options) ?? null;
|
||||||
|
if (newTrack) {
|
||||||
|
tracks.value.push(newTrack);
|
||||||
|
triggerRef(tracks);
|
||||||
|
}
|
||||||
|
return newTrack;
|
||||||
}
|
}
|
||||||
this.tracks.push(newTrack);
|
|
||||||
newTrack.addSubscriber(this, EventTypeSubscriptions);
|
|
||||||
this.publisher.notifySubs(BeatEvents.TrackListChanged);
|
|
||||||
return newTrack;
|
|
||||||
}
|
|
||||||
|
|
||||||
removeTrack(trackKey: string): void {
|
function removeTrack(index: number): void {
|
||||||
const track = this.getTrackByKey(trackKey);
|
const track = getTrackByIndex(index);
|
||||||
this.tracks.splice(this.tracks.indexOf(track), 1);
|
tracks.value.splice(index, 1);
|
||||||
this.autoBeatLength();
|
track.destroy();
|
||||||
this.publisher.notifySubs(BeatEvents.TrackListChanged);
|
triggerRef(tracks);
|
||||||
}
|
|
||||||
|
|
||||||
setTrackName(trackKey: string, newName: string): void {
|
|
||||||
this.getTrackByKey(trackKey).setName(newName);
|
|
||||||
this.publisher.notifySubs(BeatEvents.TrackOrderChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
autoBeatLengthOn(): boolean {
|
|
||||||
return this.useAutoBeatLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
private autoBeatLength(): void {
|
|
||||||
if (this.useAutoBeatLength) {
|
|
||||||
this.setBarCountInternal(this.findSmallestLoopLength() / this.timeSigUp);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
setIsUsingAutoBeatLength(isOn: boolean): void {
|
function bakeLoops(): void {
|
||||||
this.useAutoBeatLength = isOn;
|
const barCountLooped = barCount.value;
|
||||||
this.autoBeatLength();
|
useAutoBeatLength.value = false;
|
||||||
if (isOn) {
|
barCount.value = barCountLooped;
|
||||||
this.lockBars();
|
tracks.value.forEach(track => track.bakeLoops());
|
||||||
} else {
|
|
||||||
this.unlockBars();
|
|
||||||
}
|
}
|
||||||
this.publisher.notifySubs(BeatEvents.AutoBeatSettingsChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
barsLocked(): boolean {
|
function serialise(): Readonly<BeatSerial> {
|
||||||
return this.barSettingsLocked;
|
return {
|
||||||
}
|
tracks: tracks.value.map(track => track.serialise()),
|
||||||
|
barCount: barCount.value,
|
||||||
|
timeSigUp: timeSigUp.value,
|
||||||
|
globalLoopLength: globalLoopLength.value,
|
||||||
|
globalIsLooping: globalIsLooping.value,
|
||||||
|
useAutoBeatLength: useAutoBeatLength.value,
|
||||||
|
barSettingsLocked: barSettingsLocked.value,
|
||||||
|
name: name.value,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
lockBars(): void {
|
watch([barCount, timeSigUp], ([newBarCount, newTimeSigUp]) => {
|
||||||
this.barSettingsLocked = true;
|
for (const track of tracks.value) {
|
||||||
this.publisher.notifySubs(BeatEvents.LockingChanged);
|
track.barCount.value = newBarCount;
|
||||||
}
|
track.timeSigUp.value = newTimeSigUp;
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
unlockBars(): void {
|
|
||||||
this.barSettingsLocked = false;
|
|
||||||
this.publisher.notifySubs(BeatEvents.LockingChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
bakeLoops(): void {
|
|
||||||
this.tracks.forEach(track => track.bakeLoops());
|
|
||||||
}
|
|
||||||
|
|
||||||
setName(newName: string): void {
|
|
||||||
this.name.val = newName;
|
|
||||||
}
|
|
||||||
|
|
||||||
getName(): ICapsule<string> {
|
|
||||||
return this.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
serialise(): Readonly<BeatSerial> {
|
|
||||||
return {
|
return {
|
||||||
tracks: this.tracks.map(track => track.serialise()),
|
tracks,
|
||||||
barCount: this.barCount,
|
barCount,
|
||||||
timeSigUp: this.timeSigUp,
|
timeSigUp,
|
||||||
globalLoopLength: this.globalLoopLength,
|
globalLoopLength,
|
||||||
globalIsLooping: this.globalIsLooping,
|
globalIsLooping,
|
||||||
useAutoBeatLength: this.useAutoBeatLength,
|
useAutoBeatLength,
|
||||||
barSettingsLocked: this.barSettingsLocked,
|
barSettingsLocked,
|
||||||
name: this.name.val,
|
name,
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
static isBeatSerial(serial: any): serial is BeatSerial {
|
setTimeSigUp,
|
||||||
return Array.isArray(serial.tracks) &&
|
getTrackByIndex,
|
||||||
typeof serial.barCount === "number" &&
|
swapTracksByIndices,
|
||||||
typeof serial.timeSigUp === "number" &&
|
addTrack,
|
||||||
typeof serial.globalLoopLength === "number" &&
|
removeTrack,
|
||||||
typeof serial.globalIsLooping === "boolean" &&
|
bakeLoops,
|
||||||
typeof serial.useAutoBeatLength === "boolean" &&
|
serialise,
|
||||||
typeof serial.barSettingsLocked === "boolean";
|
destroy: scope.stop,
|
||||||
}
|
};
|
||||||
|
})!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Beat = ReturnType<typeof createBeat>;
|
||||||
|
|||||||
196
src/BeatStore.ts
196
src/BeatStore.ts
@@ -1,136 +1,114 @@
|
|||||||
import Beat, { BeatEvents } from "@/Beat";
|
import { type Beat, createBeat, deserialise as deserialiseBeat } from "@/Beat";
|
||||||
import { Capsule, ICapsule, ISubscriber } from "@djledda/ladder";
|
import { inject, computed, type InjectionKey, ref, shallowRef, triggerRef, watch } from "vue";
|
||||||
|
|
||||||
const EventTypeSubscriptions = [
|
function defaultMainBeatGroup(): Beat {
|
||||||
BeatEvents.TimeSigUpChanged,
|
const defaultSettings = {
|
||||||
BeatEvents.BarCountChanged,
|
barCount: 2,
|
||||||
BeatEvents.GlobalDisplayTypeChanged,
|
isLooping: false,
|
||||||
BeatEvents.TrackListChanged,
|
timeSigUp: 8,
|
||||||
BeatEvents.LockingChanged,
|
};
|
||||||
BeatEvents.AutoBeatSettingsChanged,
|
const mainBeatGroup = createBeat(defaultSettings);
|
||||||
BeatEvents.DeepChange,
|
mainBeatGroup.addTrack({ name: "LF" });
|
||||||
] as const;
|
mainBeatGroup.addTrack({ name: "LH" });
|
||||||
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
|
mainBeatGroup.addTrack({ name: "RH" });
|
||||||
|
mainBeatGroup.addTrack({ name: "RF" });
|
||||||
|
return mainBeatGroup;
|
||||||
|
}
|
||||||
|
|
||||||
export default class BeatStore implements ISubscriber<EventTypeSubscriptions> {
|
export function createBeatStore() {
|
||||||
private readonly beats: Beat[];
|
const beats = shallowRef<Beat[]>([ defaultMainBeatGroup() ]);
|
||||||
private activeBeat: ICapsule<Beat>;
|
const activeBeatIndex = ref(0);
|
||||||
private onBeatChangeCbs: (() => void)[] = [];
|
const activeBeat = computed<Beat | null>(() => beats.value[activeBeatIndex.value] ?? null);
|
||||||
private autoSave: boolean;
|
const autoSave = ref(true);
|
||||||
private orientation: "horizontal" | "vertical" | null = null;
|
const orientation = ref<'horizontal' | 'vertical' | null>(null);
|
||||||
|
|
||||||
constructor(options: { loadFromLocalStorage: boolean, autoSave: boolean }) {
|
function resetActiveBeat(): void {
|
||||||
this.autoSave = options.autoSave;
|
const current = activeBeat.value;
|
||||||
if (options.loadFromLocalStorage) {
|
beats.value[activeBeatIndex.value] = defaultMainBeatGroup();
|
||||||
const save = localStorage.getItem("drum-slayer-save");
|
current?.destroy();
|
||||||
if (save) {
|
triggerRef(beats);
|
||||||
const serial = JSON.parse(save);
|
}
|
||||||
this.beats = [BeatStore.defaultMainBeatGroup()];
|
|
||||||
this.activeBeat = Capsule.new(this.beats[0]);
|
function removeBeat(index: number): void {
|
||||||
this.loadFromSave(serial);
|
const beat = beats.value[index];
|
||||||
if (this.autoSave) {
|
beats.value.splice(index, 1);
|
||||||
this.activeBeat.watch(() => this.save("localStorage"), true);
|
beat?.destroy();
|
||||||
this.beats.forEach(beat => beat.addSubscriber(this, EventTypeSubscriptions));
|
triggerRef(beats);
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
function addNewBeat(): void {
|
||||||
|
const newBeat = defaultMainBeatGroup();
|
||||||
|
beats.value.push(newBeat);
|
||||||
|
if (autoSave.value) {
|
||||||
|
save("localStorage");
|
||||||
}
|
}
|
||||||
this.beats = [
|
triggerRef(beats);
|
||||||
BeatStore.defaultMainBeatGroup(),
|
|
||||||
];
|
|
||||||
this.activeBeat = Capsule.new(this.beats[0]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
notify(publisher: unknown, event: EventTypeSubscriptions): void {
|
function save(destination: "localStorage"): void {
|
||||||
this.save("localStorage");
|
|
||||||
}
|
|
||||||
|
|
||||||
static defaultMainBeatGroup(): Beat {
|
|
||||||
const defaultSettings = {
|
|
||||||
barCount: 2,
|
|
||||||
isLooping: false,
|
|
||||||
timeSigUp: 8,
|
|
||||||
};
|
|
||||||
const mainBeatGroup = new Beat(defaultSettings);
|
|
||||||
mainBeatGroup.addTrack({ name: "LF" });
|
|
||||||
mainBeatGroup.addTrack({ name: "LH" });
|
|
||||||
mainBeatGroup.addTrack({ name: "RH" });
|
|
||||||
mainBeatGroup.addTrack({ name: "RF" });
|
|
||||||
return mainBeatGroup;
|
|
||||||
}
|
|
||||||
|
|
||||||
getActiveBeat(): ICapsule<Beat> {
|
|
||||||
return this.activeBeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
resetActiveBeat(): void {
|
|
||||||
const index = this.beats.indexOf(this.activeBeat.val);
|
|
||||||
const reset = BeatStore.defaultMainBeatGroup();
|
|
||||||
this.beats[index] = reset;
|
|
||||||
this.activeBeat.val = reset;
|
|
||||||
}
|
|
||||||
|
|
||||||
getBeats(): Beat[] {
|
|
||||||
return this.beats.slice();
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveBeat(beat: Beat): void {
|
|
||||||
const index = this.beats.indexOf(beat);
|
|
||||||
if (index !== -1) {
|
|
||||||
this.activeBeat.val = this.beats[index];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addNewBeat(): void {
|
|
||||||
const newBeat = BeatStore.defaultMainBeatGroup();
|
|
||||||
this.beats.push(newBeat);
|
|
||||||
if (this.autoSave) {
|
|
||||||
newBeat.addSubscriber(this, EventTypeSubscriptions);
|
|
||||||
}
|
|
||||||
this.onBeatChangeCbs.forEach(cb => cb());
|
|
||||||
if (this.autoSave) {
|
|
||||||
this.save("localStorage");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeatChanges(callback: () => void) {
|
|
||||||
this.onBeatChangeCbs.push(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
save(destination: "localStorage"): void {
|
|
||||||
if (destination === "localStorage") {
|
if (destination === "localStorage") {
|
||||||
const serials = this.beats.map(beat => beat.serialise());
|
const serials = beats.value.map(beat => beat.serialise());
|
||||||
localStorage.setItem("drum-slayer-save", JSON.stringify({
|
localStorage.setItem("drum-slayer-save", JSON.stringify({
|
||||||
beats: serials,
|
beats: serials,
|
||||||
activeBeatIndex: this.beats.indexOf(this.activeBeat.val),
|
activeBeatIndex: activeBeatIndex.value,
|
||||||
orientation: this.orientation,
|
orientation: orientation.value,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFromSave(source: any): void {
|
function loadFromSave(source: any): void {
|
||||||
this.beats.length = 0;
|
beats.value.length = 0;
|
||||||
if (Array.isArray(source.beats)
|
if (Array.isArray(source.beats)
|
||||||
&& (typeof source.activeBeatIndex === "number" || typeof source.activeBeatIndex === "undefined")
|
&& (typeof source.activeBeatIndex === "number" || typeof source.activeBeatIndex === "undefined")
|
||||||
&& typeof source.orientation === "string") {
|
&& typeof source.orientation === "string") {
|
||||||
try {
|
try {
|
||||||
source.beats.forEach((beat: any) => this.beats.push(Beat.deserialise(beat)));
|
source.beats.forEach((beat: any) => beats.value.push(deserialiseBeat(beat)));
|
||||||
if (typeof source.activeBeatIndex === "number") {
|
if (typeof source.activeBeatIndex === "number") {
|
||||||
this.activeBeat.val = this.beats[source.activeBeatIndex];
|
activeBeatIndex.value = source.activeBeatIndex;
|
||||||
}
|
}
|
||||||
this.orientation = source.orientation;
|
orientation.value = source.orientation;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
resetActiveBeat();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setOrientation(orientation: "horizontal" | "vertical"): void {
|
function bakeAll(): void {
|
||||||
this.orientation = orientation;
|
activeBeat.value?.bakeLoops();
|
||||||
this.save("localStorage");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getSavedOrientation(): "horizontal" | "vertical" | null {
|
watch([activeBeatIndex, orientation, beats], () => {
|
||||||
return this.orientation;
|
save('localStorage');
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedItem = localStorage.getItem('drum-slayer-save');
|
||||||
|
if (savedItem) {
|
||||||
|
const serial = JSON.parse(savedItem);
|
||||||
|
beats.value = [defaultMainBeatGroup()];
|
||||||
|
loadFromSave(serial);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
return {
|
||||||
|
beats,
|
||||||
|
activeBeatIndex,
|
||||||
|
activeBeat,
|
||||||
|
autoSave,
|
||||||
|
orientation,
|
||||||
|
|
||||||
|
save,
|
||||||
|
addNewBeat,
|
||||||
|
removeBeat,
|
||||||
|
resetActiveBeat,
|
||||||
|
bakeAll,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BeatStore = ReturnType<typeof createBeatStore>;
|
||||||
|
|
||||||
|
export const BeatStoreKey = Symbol('BeatStore') as InjectionKey<BeatStore>;
|
||||||
|
|
||||||
|
export function useBeatStore(): BeatStore {
|
||||||
|
return inject(BeatStoreKey, createBeatStore, true);
|
||||||
|
}
|
||||||
|
|||||||
438
src/Track.ts
438
src/Track.ts
@@ -1,243 +1,263 @@
|
|||||||
import TrackUnit, { TrackUnitType } from "@/TrackUnit";
|
|
||||||
import { isPosInt } from "@/utils";
|
import { isPosInt } from "@/utils";
|
||||||
import { IPublisher, ISubscriber, Publisher } from "@djledda/ladder";
|
import { ref, shallowRef, computed, watch, reactive, triggerRef, effectScope } from "vue";
|
||||||
|
|
||||||
export type TrackInitOptions = {
|
export type TrackInitOptions = {
|
||||||
timeSig?: {
|
timeSig?: {
|
||||||
up: number,
|
up: number,
|
||||||
down: number,
|
down: number,
|
||||||
},
|
},
|
||||||
|
barCount?: number,
|
||||||
|
units?: TrackUnit[],
|
||||||
name?: string,
|
name?: string,
|
||||||
bars?: number,
|
bars?: number,
|
||||||
isLooping?: boolean,
|
isLooping?: boolean,
|
||||||
loopLength?: number,
|
loopLength?: number,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const enum TrackEvents {
|
|
||||||
NewTimeSig="be-0",
|
|
||||||
NewBarCount="be-1",
|
|
||||||
NewName="be-2",
|
|
||||||
DisplayTypeChanged="be-3",
|
|
||||||
LoopLengthChanged="be-4",
|
|
||||||
WantsRemoval="be-5",
|
|
||||||
Baked="be-6",
|
|
||||||
DeepChange="be-7",
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TrackSerial = {
|
export type TrackSerial = {
|
||||||
name: string,
|
name: string,
|
||||||
timeSigUp: number,
|
timeSigUp: number,
|
||||||
timeSigDown: number,
|
timeSigDown: number,
|
||||||
units: {
|
units: {
|
||||||
isOn: boolean[],
|
isOn: boolean[],
|
||||||
type: TrackUnitType[],
|
type: number[],
|
||||||
stickingType: TrackUnitStickingType[],
|
stickingType: number[],
|
||||||
},
|
},
|
||||||
barCount: number,
|
barCount: number,
|
||||||
loopLength: number,
|
loopLength: number,
|
||||||
looping: boolean,
|
looping: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class Track implements IPublisher<TrackEvents> {
|
export const TrackUnitTypeList = [ "Normal", "GhostNote", "Accent", "GhostNoteAccent" ] as const;
|
||||||
private static count = 0;
|
export type TrackUnitType = typeof TrackUnitTypeList[number];
|
||||||
private readonly key: string;
|
|
||||||
private name: string;
|
|
||||||
private timeSigUp = 4;
|
|
||||||
private timeSigDown = 4;
|
|
||||||
private readonly unitRecord: TrackUnit[] = [];
|
|
||||||
private barCount = 1;
|
|
||||||
private publisher = new Publisher<TrackEvents, Track>(this);
|
|
||||||
private loopLength: number;
|
|
||||||
private looping: boolean;
|
|
||||||
|
|
||||||
constructor(options?: TrackInitOptions) {
|
export const TrackUnitStickingTypeList = [ "none", "lh", "rh", "lf", "rf" ] as const;
|
||||||
this.key = `B-${Track.count}`;
|
export type TrackUnitStickingType = typeof TrackUnitStickingTypeList[number];
|
||||||
this.name = options?.name ?? this.key;
|
|
||||||
this.setTimeSignature({ up: options?.timeSig?.up ?? 4, down: options?.timeSig?.down ?? 4 });
|
export function isValidTimeSigRange(sig: number): boolean {
|
||||||
this.setBarCount(options?.bars ?? 4);
|
return sig >= 2 && sig <= 32;
|
||||||
Track.count++;
|
}
|
||||||
this.loopLength = options?.loopLength ?? this.timeSigUp * this.barCount;
|
|
||||||
this.looping = options?.isLooping ?? false;
|
function isTrackSerial(serial: any): serial is TrackSerial {
|
||||||
|
const correctTypes = typeof serial.name === "string" &&
|
||||||
|
typeof serial.timeSigUp === "number" &&
|
||||||
|
typeof serial.timeSigDown === "number" &&
|
||||||
|
typeof serial.units === "object" &&
|
||||||
|
Array.isArray(serial.units.isOn) &&
|
||||||
|
Array.isArray(serial.units.type) &&
|
||||||
|
Array.isArray(serial.units.stickingType) &&
|
||||||
|
typeof serial.barCount === "number" &&
|
||||||
|
typeof serial.loopLength === "number" &&
|
||||||
|
typeof serial.looping === "boolean";
|
||||||
|
return correctTypes && serial.units.isOn.length === serial.units.type.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserialise(serial: Record<string, unknown>) {
|
||||||
|
if (!isTrackSerial(serial)) {
|
||||||
|
throw new Error("Invalid track serial.");
|
||||||
}
|
}
|
||||||
|
const units = serial.units.isOn.map((isOn, i) => ({
|
||||||
|
on: isOn,
|
||||||
|
type: serial.units.type[i] ?? 0,
|
||||||
|
stickingType: serial.units.stickingType[i] ?? 0,
|
||||||
|
}));
|
||||||
|
return createTrack({
|
||||||
|
bars: serial.barCount,
|
||||||
|
isLooping: serial.looping,
|
||||||
|
loopLength: serial.loopLength,
|
||||||
|
name: serial.name,
|
||||||
|
timeSig: {
|
||||||
|
up: serial.timeSigUp,
|
||||||
|
down: serial.timeSigDown,
|
||||||
|
},
|
||||||
|
units,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static deserialise(serial: any): Track {
|
export type TrackUnit = {
|
||||||
if (!Track.isTrackSerial(serial)) {
|
on: boolean,
|
||||||
throw new Error("Invalid track serial.");
|
type: number,
|
||||||
}
|
stickingType: number,
|
||||||
const track = new Track({
|
};
|
||||||
bars: serial.barCount,
|
|
||||||
isLooping: serial.looping,
|
export function createTrack(options: TrackInitOptions) {
|
||||||
loopLength: serial.loopLength,
|
const scope = effectScope();
|
||||||
name: serial.name,
|
return scope.run(() => {
|
||||||
timeSig: {
|
const name = ref(options.name ?? 'New Track');
|
||||||
up: serial.timeSigUp,
|
const timeSig = reactive({ up: 4, down: 4 });
|
||||||
down: serial.timeSigDown,
|
const timeSigUp = computed({
|
||||||
|
get() {
|
||||||
|
return timeSig.up;
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
if (isValidTimeSigRange(val)) {
|
||||||
|
timeSig.up = val | 0;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const units = serial.units.isOn.map((isOn, i) => new TrackUnit({
|
const timeSigDown = computed({
|
||||||
on: isOn,
|
get() {
|
||||||
type: serial.units.type[i],
|
return timeSig.down;
|
||||||
stickingType: serial.units.stickingType[i],
|
|
||||||
parent: track,
|
|
||||||
}));
|
|
||||||
track.unitRecord.length = 0;
|
|
||||||
track.unitRecord.push(...units);
|
|
||||||
return track;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoopLength(loopLength: number): void {
|
|
||||||
if (!isPosInt(loopLength) || loopLength < 2) {
|
|
||||||
loopLength = this.loopLength;
|
|
||||||
}
|
|
||||||
this.loopLength = loopLength;
|
|
||||||
this.publisher.notifySubs(TrackEvents.LoopLengthChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLooping(isLooping: boolean): void {
|
|
||||||
this.looping = isLooping;
|
|
||||||
this.publisher.notifySubs(TrackEvents.DisplayTypeChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
addSubscriber(subscriber: ISubscriber<TrackEvents>, eventType: TrackEvents | TrackEvents[]): { unbind: () => void } {
|
|
||||||
return this.publisher.addSubscriber(subscriber, eventType);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeSignature(timeSig: {up?: number, down?: number}): void {
|
|
||||||
if (timeSig.up && Track.isValidTimeSigRange(timeSig.up)) {
|
|
||||||
this.timeSigUp = timeSig.up | 0;
|
|
||||||
}
|
|
||||||
if (timeSig.down && Track.isValidTimeSigRange(timeSig.down)) {
|
|
||||||
this.timeSigDown = timeSig.down | 0;
|
|
||||||
}
|
|
||||||
this.updateTrackUnitLength();
|
|
||||||
this.publisher.notifySubs(TrackEvents.NewTimeSig);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeSigUp(timeSigUp: number): void {
|
|
||||||
this.setTimeSignature({ up: timeSigUp });
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeSigDown(timeSigUp: number): void {
|
|
||||||
this.setTimeSignature({ down: timeSigUp });
|
|
||||||
}
|
|
||||||
|
|
||||||
setBarCount(barCount: number): void {
|
|
||||||
if (!isPosInt(barCount) || barCount == this.barCount) {
|
|
||||||
barCount = this.barCount;
|
|
||||||
}
|
|
||||||
this.barCount = barCount;
|
|
||||||
this.updateTrackUnitLength();
|
|
||||||
this.publisher.notifySubs(TrackEvents.NewBarCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
getUnitByIndex(index: number): TrackUnit | null {
|
|
||||||
if (this.looping) {
|
|
||||||
index %= this.loopLength;
|
|
||||||
}
|
|
||||||
return this.unitRecord[index] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateTrackUnitLength() {
|
|
||||||
const newBarCount = this.barCount * this.timeSigUp;
|
|
||||||
if (newBarCount < this.unitRecord.length) {
|
|
||||||
this.unitRecord.splice(this.barCount * this.timeSigUp, this.unitRecord.length - newBarCount);
|
|
||||||
} else if (newBarCount > this.unitRecord.length) {
|
|
||||||
const barsToAdd = newBarCount - this.unitRecord.length;
|
|
||||||
for (let i = 0; i < barsToAdd; i++) {
|
|
||||||
this.unitRecord.push(new TrackUnit({
|
|
||||||
parent: this,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getTimeSigUp(): number {
|
|
||||||
return this.timeSigUp;
|
|
||||||
}
|
|
||||||
|
|
||||||
getTimeSigDown(): number {
|
|
||||||
return this.timeSigDown;
|
|
||||||
}
|
|
||||||
|
|
||||||
getBarCount(): number {
|
|
||||||
return this.barCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
getKey(): string {
|
|
||||||
return this.key;
|
|
||||||
}
|
|
||||||
|
|
||||||
static isValidTimeSigRange(sig: number): boolean {
|
|
||||||
return sig >= 2 && sig <= 32;
|
|
||||||
}
|
|
||||||
|
|
||||||
setName(newName: string): void {
|
|
||||||
this.name = newName;
|
|
||||||
this.publisher.notifySubs(TrackEvents.NewName);
|
|
||||||
}
|
|
||||||
|
|
||||||
getName(): string {
|
|
||||||
return this.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLooping(): boolean {
|
|
||||||
return this.looping;
|
|
||||||
}
|
|
||||||
|
|
||||||
getLoopLength(): number {
|
|
||||||
return this.loopLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(): void {
|
|
||||||
this.publisher.notifySubs(TrackEvents.WantsRemoval);
|
|
||||||
}
|
|
||||||
|
|
||||||
bakeLoops(): void {
|
|
||||||
if (this.isLooping()) {
|
|
||||||
this.unitRecord.forEach((unit, i) => {
|
|
||||||
const reprUnitAtPos = this.getUnitByIndex(i);
|
|
||||||
if (reprUnitAtPos) {
|
|
||||||
unit.mimic(reprUnitAtPos);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.publisher.notifySubs(TrackEvents.Baked);
|
|
||||||
this.setLooping(false);
|
|
||||||
} else {
|
|
||||||
this.publisher.notifySubs(TrackEvents.Baked);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
serialise(): Readonly<TrackSerial> {
|
|
||||||
return {
|
|
||||||
name: this.name,
|
|
||||||
timeSigUp: this.timeSigUp,
|
|
||||||
timeSigDown: this.timeSigDown,
|
|
||||||
units: {
|
|
||||||
isOn: this.unitRecord.map(unit => unit.isOn()),
|
|
||||||
type: this.unitRecord.map(unit => unit.getType()),
|
|
||||||
stickingType: this.unitRecord.map(unit => unit.getStickingType()),
|
|
||||||
},
|
},
|
||||||
barCount: this.barCount,
|
set(val) {
|
||||||
loopLength: this.loopLength,
|
if (isValidTimeSigRange(val)) {
|
||||||
looping: this.looping,
|
timeSig.down = val | 0;
|
||||||
} as const;
|
}
|
||||||
}
|
},
|
||||||
|
});
|
||||||
|
const unitRecord = shallowRef<TrackUnit[]>([]);
|
||||||
|
const barCount = ref(options.barCount ?? 4);
|
||||||
|
const loopLengthInternal = ref(options?.loopLength ?? timeSigUp.value * barCount.value);
|
||||||
|
const loopLength = computed({
|
||||||
|
get() {
|
||||||
|
return loopLengthInternal.value;
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
if (!isPosInt(val) || val < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loopLengthInternal.value = val;
|
||||||
|
triggerRef(unitRecord);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const looping = ref(options?.isLooping ?? false);
|
||||||
|
|
||||||
static isTrackSerial(serial: any): serial is TrackSerial {
|
function getUnitByIndex(index: number): TrackUnit | null {
|
||||||
const correctTypes = typeof serial.name === "string" &&
|
if (looping.value) {
|
||||||
typeof serial.timeSigUp === "number" &&
|
index %= loopLength.value;
|
||||||
typeof serial.timeSigDown === "number" &&
|
}
|
||||||
typeof serial.units === "object" &&
|
return unitRecord.value[index] ?? null;
|
||||||
Array.isArray(serial.units.isOn) &&
|
}
|
||||||
Array.isArray(serial.units.type) &&
|
|
||||||
Array.isArray(serial.units.stickingType) &&
|
|
||||||
typeof serial.barCount === "number" &&
|
|
||||||
typeof serial.loopLength === "number" &&
|
|
||||||
typeof serial.looping === "boolean";
|
|
||||||
return correctTypes && serial.units.isOn.length === serial.units.type.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
alertDeepChange(): void {
|
function updateTrackUnitLength() {
|
||||||
this.publisher.notifySubs(TrackEvents.DeepChange);
|
const newBarCount = barCount.value * timeSigUp.value;
|
||||||
}
|
if (newBarCount < unitRecord.value.length) {
|
||||||
|
unitRecord.value.splice(barCount.value * timeSigUp.value, unitRecord.value.length - newBarCount);
|
||||||
|
} else if (newBarCount > unitRecord.value.length) {
|
||||||
|
const barsToAdd = newBarCount - unitRecord.value.length;
|
||||||
|
for (let i = 0; i < barsToAdd; i++) {
|
||||||
|
unitRecord.value.push(createTrackUnit());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
triggerRef(unitRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bakeLoops(): void {
|
||||||
|
if (looping.value) {
|
||||||
|
unitRecord.value.forEach((unit, i) => {
|
||||||
|
const reprUnitAtPos = getUnitByIndex(i);
|
||||||
|
if (reprUnitAtPos) {
|
||||||
|
mimicUnit(unit, reprUnitAtPos);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
looping.value = false;
|
||||||
|
}
|
||||||
|
barCount.value
|
||||||
|
triggerRef(unitRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialise(): Readonly<TrackSerial> {
|
||||||
|
return {
|
||||||
|
name: name.value,
|
||||||
|
timeSigUp: timeSigUp.value,
|
||||||
|
timeSigDown: timeSigDown.value,
|
||||||
|
units: {
|
||||||
|
isOn: unitRecord.value.map(unit => unit.on),
|
||||||
|
type: unitRecord.value.map(unit => unit.type),
|
||||||
|
stickingType: unitRecord.value.map(unit => unit.stickingType),
|
||||||
|
},
|
||||||
|
barCount: barCount.value,
|
||||||
|
loopLength: loopLength.value,
|
||||||
|
looping: looping.value,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTrackUnit(): TrackUnit {
|
||||||
|
return {
|
||||||
|
on: false,
|
||||||
|
type: 0,
|
||||||
|
stickingType: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStickingType(index: number, stickingType: number): void {
|
||||||
|
const unit = getUnitByIndex(index);
|
||||||
|
if (!unit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unit.stickingType = stickingType;
|
||||||
|
triggerRef(unitRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUnitOn(index: number, on: boolean): void {
|
||||||
|
const unit = getUnitByIndex(index);
|
||||||
|
if (!unit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unit.on = on;
|
||||||
|
triggerRef(unitRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUnit(index: number, update: Partial<TrackUnit>) {
|
||||||
|
const unit = getUnitByIndex(index);
|
||||||
|
if (!unit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object.assign(unit, update);
|
||||||
|
triggerRef(unitRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUnit(index: number): void {
|
||||||
|
const unit = getUnitByIndex(index);
|
||||||
|
if (!unit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unit.on = !unit.on;
|
||||||
|
triggerRef(unitRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateUnit(index: number): void {
|
||||||
|
const unit = getUnitByIndex(index);
|
||||||
|
if (!unit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (unit.type === TrackUnitTypeList.length - 1) {
|
||||||
|
unit.type = 0;
|
||||||
|
} else {
|
||||||
|
unit.type += 1;
|
||||||
|
}
|
||||||
|
triggerRef(unitRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mimicUnit(unitA: TrackUnit, unitB: TrackUnit): void {
|
||||||
|
unitA.on = unitB.on;
|
||||||
|
unitA.type = unitB.type;
|
||||||
|
unitA.stickingType = unitB.stickingType;
|
||||||
|
triggerRef(unitRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([barCount, timeSigDown, timeSigUp], () => updateTrackUnitLength(), { immediate: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
timeSigUp,
|
||||||
|
timeSigDown,
|
||||||
|
unitRecord,
|
||||||
|
barCount,
|
||||||
|
loopLength,
|
||||||
|
looping,
|
||||||
|
|
||||||
|
serialise,
|
||||||
|
bakeLoops,
|
||||||
|
rotateUnit,
|
||||||
|
toggleUnit,
|
||||||
|
setUnitOn,
|
||||||
|
updateUnit,
|
||||||
|
getUnitByIndex,
|
||||||
|
setStickingType,
|
||||||
|
destroy: scope.stop,
|
||||||
|
};
|
||||||
|
})!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Track = ReturnType<typeof createTrack>;
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import Track from "@/Track";
|
|
||||||
import { IPublisher, ISubscriber, Publisher } from "@djledda/ladder";
|
|
||||||
|
|
||||||
export const TrackUnitTypeList = [ "Normal", "GhostNote", "Accent", "GhostNoteAccent" ] as const;
|
|
||||||
export type TrackUnitType = typeof TrackUnitTypeList[number];
|
|
||||||
|
|
||||||
export const TrackUnitStickingTypeList = [ "none", "lh", "rh", "lf", "rf" ] as const;
|
|
||||||
export type TrackUnitStickingType = typeof TrackUnitStickingTypeList[number];
|
|
||||||
|
|
||||||
export const enum TrackUnitEvent {
|
|
||||||
Toggle="tue-0",
|
|
||||||
On="tue-1",
|
|
||||||
Off="tue-2",
|
|
||||||
TypeChange="tue-3",
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class TrackUnit implements IPublisher<TrackUnitEvent> {
|
|
||||||
private publisher: Publisher<TrackUnitEvent, TrackUnit> = new Publisher<TrackUnitEvent, TrackUnit>(this);
|
|
||||||
private on = false;
|
|
||||||
private typeIndex = 0;
|
|
||||||
private stickingType: TrackUnitStickingType = "none";
|
|
||||||
private parent: Track;
|
|
||||||
|
|
||||||
constructor(options: {
|
|
||||||
on?: boolean,
|
|
||||||
type?: TrackUnitType,
|
|
||||||
stickingType?: TrackUnitStickingType,
|
|
||||||
parent: Track,
|
|
||||||
}) {
|
|
||||||
this.parent = options.parent;
|
|
||||||
this.on = options.on ?? false;
|
|
||||||
this.setType(options.type ?? "Normal");
|
|
||||||
this.setStickingType(options.stickingType ?? "none");
|
|
||||||
}
|
|
||||||
|
|
||||||
addSubscriber(subscriber: ISubscriber<TrackUnitEvent>, eventType: TrackUnitEvent[]): { unbind: () => void } {
|
|
||||||
return this.publisher.addSubscriber(subscriber, eventType);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle(): void {
|
|
||||||
this.on = !this.on;
|
|
||||||
this.publisher.notifySubs(TrackUnitEvent.Toggle);
|
|
||||||
if (this.on) {
|
|
||||||
this.publisher.notifySubs(TrackUnitEvent.On);
|
|
||||||
} else {
|
|
||||||
this.publisher.notifySubs(TrackUnitEvent.Off);
|
|
||||||
}
|
|
||||||
this.parent.alertDeepChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
setOn(on: boolean): void {
|
|
||||||
this.on = on;
|
|
||||||
this.publisher.notifySubs(this.on ? TrackUnitEvent.On : TrackUnitEvent.Off);
|
|
||||||
this.parent.alertDeepChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
setType(type: TrackUnitType): void {
|
|
||||||
this.typeIndex = TrackUnitTypeList.indexOf(type);
|
|
||||||
this.publisher.notifySubs(TrackUnitEvent.TypeChange);
|
|
||||||
this.parent.alertDeepChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
setStickingType(type: TrackUnitStickingType) {
|
|
||||||
this.stickingType = type;
|
|
||||||
this.publisher.notifySubs(TrackUnitEvent.TypeChange);
|
|
||||||
this.parent.alertDeepChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
getStickingType() {
|
|
||||||
return this.stickingType;
|
|
||||||
}
|
|
||||||
|
|
||||||
getType(): TrackUnitType {
|
|
||||||
return TrackUnitTypeList[this.typeIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
rotateType(): void {
|
|
||||||
if (this.typeIndex === TrackUnitTypeList.length - 1) {
|
|
||||||
this.typeIndex = 0;
|
|
||||||
} else {
|
|
||||||
this.typeIndex += 1;
|
|
||||||
}
|
|
||||||
this.publisher.notifySubs(TrackUnitEvent.TypeChange);
|
|
||||||
this.parent.alertDeepChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
isOn(): boolean {
|
|
||||||
return this.on;
|
|
||||||
}
|
|
||||||
|
|
||||||
mimic(trackUnit: TrackUnit): void {
|
|
||||||
this.setOn(trackUnit.isOn());
|
|
||||||
this.setType(trackUnit.getType());
|
|
||||||
this.setStickingType(trackUnit.getStickingType());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
20
src/main.ts
20
src/main.ts
@@ -1,15 +1,9 @@
|
|||||||
import RootView from "@/ui/Root/RootView";
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import Root from '@/ui/Root/Root.vue'
|
||||||
import "@/ui/global.css";
|
import "@/ui/global.css";
|
||||||
import { bootstrap } from "@djledda/ladder";
|
|
||||||
|
|
||||||
try {
|
const app = createApp(Root, { title: "Drum Slayer" });
|
||||||
const appRoot = new RootView({
|
app.use(createPinia());
|
||||||
orientation: "vertical",
|
app.mount('#app');
|
||||||
title: "Drum Slayer",
|
|
||||||
});
|
|
||||||
window.appRoot = appRoot;
|
|
||||||
bootstrap(appRoot, "app");
|
|
||||||
console.log("OK!");
|
|
||||||
} catch (e) {
|
|
||||||
console.error("FUCK!", e);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
import {TrackUnitType} from "./TrackUnit";
|
|
||||||
import Track from "./Beat";
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
.beat {
|
|
||||||
padding: 1em;
|
|
||||||
overflow-x: scroll;
|
|
||||||
overflow-y: hidden;
|
|
||||||
display: flex;
|
|
||||||
width: inherit;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vertical-mode .beat {
|
|
||||||
padding-bottom: 2em;
|
|
||||||
align-items: center;
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: scroll;
|
|
||||||
height: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.beat-title {
|
|
||||||
color: var(--color-title-light);
|
|
||||||
text-align: center;
|
|
||||||
width: fit-content;
|
|
||||||
padding-left: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vertical-mode .beat-title {
|
|
||||||
color: var(--color-title-light);
|
|
||||||
text-align: center;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.beat-track-container {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.beat-titles-container {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vertical-mode .beat-main-container {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vertical-mode .beat-titles-container {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.beat-main-container {
|
|
||||||
display: flex;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.beat-track-title {
|
|
||||||
height: 36px;
|
|
||||||
line-height: 36px;
|
|
||||||
margin: 0;
|
|
||||||
flex: 1;
|
|
||||||
text-align: right;
|
|
||||||
display: block;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vertical-mode .beat-track-title {
|
|
||||||
height: auto;
|
|
||||||
transform: rotate(330deg);
|
|
||||||
transform-origin: bottom;
|
|
||||||
width: 36px;
|
|
||||||
display: inline-block;
|
|
||||||
writing-mode: vertical-rl;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
119
src/ui/Beat/Beat.vue
Normal file
119
src/ui/Beat/Beat.vue
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<template>
|
||||||
|
<div class="beat" :class="{ vertical: false }">
|
||||||
|
<h2 class="beat-title">{{ beat!.name.value }}</h2>
|
||||||
|
<div class="beat-main-container">
|
||||||
|
<div class="beat-titles-container">
|
||||||
|
<div class="beat-track-title" v-for="title in titles">{{ title }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="beat-track-container">
|
||||||
|
<track-view
|
||||||
|
v-for="(_, i) in beat!.tracks.value"
|
||||||
|
:beat-index="beatIndex"
|
||||||
|
:track-index="i" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import TrackView from "@/ui/Track/Track.vue";
|
||||||
|
import EditableTextField from "@/ui/Widgets/EditableTextField/EditableTextField.vue";
|
||||||
|
import { useAppStateStore } from '@/AppState';
|
||||||
|
import { useBeatStore } from '@/BeatStore';
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
beatIndex: number,
|
||||||
|
orientation?: "horizontal" | "vertical",
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const appState = useAppStateStore();
|
||||||
|
const { beats } = useBeatStore();
|
||||||
|
const beat = computed(() => beats.value[props.beatIndex] ?? null);
|
||||||
|
|
||||||
|
const titles = computed(() => {
|
||||||
|
const titles = beats.value[props.beatIndex]?.tracks.value.map(track => track.name.value) ?? [];
|
||||||
|
if (props.orientation === 'horizontal') {
|
||||||
|
titles.reverse();
|
||||||
|
}
|
||||||
|
return titles;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.beat {
|
||||||
|
padding: 1em;
|
||||||
|
overflow-x: scroll;
|
||||||
|
overflow-y: hidden;
|
||||||
|
display: flex;
|
||||||
|
width: inherit;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-mode .beat {
|
||||||
|
padding-bottom: 2em;
|
||||||
|
align-items: center;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: scroll;
|
||||||
|
height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beat-title {
|
||||||
|
color: var(--color-title-light);
|
||||||
|
text-align: center;
|
||||||
|
width: fit-content;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-mode .beat-title {
|
||||||
|
color: var(--color-title-light);
|
||||||
|
text-align: center;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beat-track-container {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beat-titles-container {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-mode .beat-main-container {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-mode .beat-titles-container {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beat-main-container {
|
||||||
|
display: flex;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beat-track-title {
|
||||||
|
height: 36px;
|
||||||
|
line-height: 36px;
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
text-align: right;
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-mode .beat-track-title {
|
||||||
|
height: auto;
|
||||||
|
transform: rotate(330deg);
|
||||||
|
transform-origin: bottom;
|
||||||
|
width: 36px;
|
||||||
|
display: inline-block;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { h, ISubscriber, ISubscription, Rung, RungOptions } from "@djledda/ladder";
|
|
||||||
import Beat, { BeatEvents } from "@/Beat";
|
|
||||||
import TrackView from "@/ui/Track/TrackView";
|
|
||||||
import "./Beat.css";
|
|
||||||
import EditableTextFieldView from "@/ui/Widgets/EditableTextFIeld/EditableTextFieldView";
|
|
||||||
import AppState from "@/AppState";
|
|
||||||
|
|
||||||
export type BeatUINodeOptions = RungOptions & {
|
|
||||||
state: AppState,
|
|
||||||
beat: Beat,
|
|
||||||
orientation?: "horizontal" | "vertical",
|
|
||||||
};
|
|
||||||
|
|
||||||
const EventTypeSubscriptions = [
|
|
||||||
BeatEvents.TrackListChanged,
|
|
||||||
] as const;
|
|
||||||
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
|
|
||||||
|
|
||||||
export default class BeatView extends Rung<HTMLElement> implements ISubscriber<EventTypeSubscriptions> {
|
|
||||||
private beat: Beat;
|
|
||||||
private title: EditableTextFieldView;
|
|
||||||
private trackViews: TrackView[] = [];
|
|
||||||
private titles: HTMLHeadingElement[] = [];
|
|
||||||
private currentOrientation: "vertical" | "horizontal";
|
|
||||||
private subscription: ISubscription;
|
|
||||||
private state: AppState;
|
|
||||||
|
|
||||||
constructor(options: BeatUINodeOptions) {
|
|
||||||
super(options);
|
|
||||||
this.state = options.state;
|
|
||||||
this.beat = options.beat;
|
|
||||||
this.currentOrientation = options.orientation ?? "horizontal";
|
|
||||||
this.subscription = this.beat.addSubscriber(this, EventTypeSubscriptions);
|
|
||||||
this.title = new EditableTextFieldView({
|
|
||||||
setter: (text: string) => this.beat.setName(text),
|
|
||||||
noEmpty: true,
|
|
||||||
initialText: this.beat.getName().val,
|
|
||||||
});
|
|
||||||
this.setupTrackViews();
|
|
||||||
}
|
|
||||||
|
|
||||||
notify(publisher: unknown, event: EventTypeSubscriptions): void {
|
|
||||||
if (event === BeatEvents.TrackListChanged) {
|
|
||||||
this.setupTrackViews();
|
|
||||||
this.redraw();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupTrackViews(): void {
|
|
||||||
const newCount = this.beat.getTrackCount();
|
|
||||||
for (let i = 0; i < newCount; i++) {
|
|
||||||
const track = this.beat.getTrackByIndex(i);
|
|
||||||
if (track && this.trackViews[i]) {
|
|
||||||
const title = this.trackViews[i].getTitleNode();
|
|
||||||
if (title) {
|
|
||||||
this.titles[i] = title;
|
|
||||||
}
|
|
||||||
this.trackViews[i].setTrack(track);
|
|
||||||
} else {
|
|
||||||
this.trackViews.push(new TrackView({ state: this.state, track: this.beat.getTrackByIndex(i) }));
|
|
||||||
this.titles.push(this.trackViews[i].getTitleNode());
|
|
||||||
this.titles[this.titles.length - 1].classList.add("beat-track-title");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.titles.splice(newCount, this.titles.length - newCount);
|
|
||||||
const deadTrackViews = this.trackViews.splice(newCount, this.trackViews.length - newCount);
|
|
||||||
deadTrackViews.forEach(beatView => beatView.setTrack(null));
|
|
||||||
if (this.currentOrientation === "horizontal") {
|
|
||||||
this.reverseDisplayOrder();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setOrientation(orientation: "vertical" | "horizontal"): void {
|
|
||||||
if (this.currentOrientation !== orientation) {
|
|
||||||
this.reverseDisplayOrder();
|
|
||||||
this.currentOrientation = orientation;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private reverseDisplayOrder(): void {
|
|
||||||
this.titles.reverse();
|
|
||||||
this.trackViews.reverse();
|
|
||||||
this.render().classList.toggle("vertical");
|
|
||||||
this.redraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
private onNewBeat(): void {
|
|
||||||
this.beat.getName().watch((newVal) => {
|
|
||||||
this.title.setText(newVal);
|
|
||||||
});
|
|
||||||
this.title.setText(this.beat.getName().val);
|
|
||||||
EventTypeSubscriptions.forEach(event => this.notify(this, event));
|
|
||||||
this.setupTrackViews();
|
|
||||||
this.redraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
setBeat(newBeat: Beat): void {
|
|
||||||
this.beat = newBeat;
|
|
||||||
this.subscription.unbind();
|
|
||||||
this.subscription = this.beat.addSubscriber(this, BeatEvents.TrackListChanged);
|
|
||||||
this.onNewBeat();
|
|
||||||
}
|
|
||||||
|
|
||||||
build(): HTMLDivElement {
|
|
||||||
return <div className={"beat"}>
|
|
||||||
<h2 className={"beat-title"}>{this.title}</h2>
|
|
||||||
<div className={"beat-main-container"}>
|
|
||||||
<div className={"beat-titles-container"}>{...this.titles}</div>
|
|
||||||
<div className={"beat-track-container"}>{...this.trackViews}</div>
|
|
||||||
</div>
|
|
||||||
</div> as HTMLDivElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
.beat-settings {
|
|
||||||
}
|
|
||||||
|
|
||||||
.beat-settings-options {
|
|
||||||
padding: 1em;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-evenly;
|
|
||||||
}
|
|
||||||
|
|
||||||
.beat-settings-option {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.beat-settings-option-group {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.beat-settings-option-group.visible {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
65
src/ui/BeatSettings/BeatSettings.vue
Normal file
65
src/ui/BeatSettings/BeatSettings.vue
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<div class="beat-settings" v-if="beat">
|
||||||
|
<div class="beat-settings-options">
|
||||||
|
<div class="beat-settings-boxes beat-settings-option">
|
||||||
|
<number-input label="Bars: " v-model="beat!.barCount.value" :disabled="beat!.barSettingsLocked.value" />
|
||||||
|
</div>
|
||||||
|
<div class="beat-settings-bar-count beat-settings-option">
|
||||||
|
<number-input label="Boxes per bar: " v-model="beat!.timeSigUp.value" />
|
||||||
|
</div>
|
||||||
|
<div class="beat-settings-bar-count beat-settings-option">
|
||||||
|
<bool-box label="Auto beat length: " v-model="beat!.useAutoBeatLength.value" />
|
||||||
|
</div>
|
||||||
|
<action-button
|
||||||
|
label="New Track"
|
||||||
|
@click="() => beat!.addTrack()" />
|
||||||
|
<div>
|
||||||
|
<track-settings
|
||||||
|
v-for="(_, i) in beat!.tracks.value ?? []"
|
||||||
|
:key="i"
|
||||||
|
:beat-index="beatIndex"
|
||||||
|
:track-index="i" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import NumberInput from "@/ui/Widgets/NumberInput/NumberInput.vue";
|
||||||
|
import BoolBox from "@/ui/Widgets/BoolBox/BoolBox.vue";
|
||||||
|
import TrackSettings from "@/ui/TrackSettings/TrackSettings.vue";
|
||||||
|
import { useBeatStore } from '@/BeatStore';
|
||||||
|
import ActionButton from "@/ui/Widgets/ActionButton/ActionButton.vue";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
beatIndex: number,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { beats } = useBeatStore();
|
||||||
|
|
||||||
|
const beat = computed(() => beats.value[props.beatIndex] ?? null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.beat-settings {
|
||||||
|
}
|
||||||
|
|
||||||
|
.beat-settings-options {
|
||||||
|
padding: 1em;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beat-settings-option {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beat-settings-option-group {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.beat-settings-option-group.visible {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import "./BeatSettings.css";
|
|
||||||
import NumberInputView from "@/ui/Widgets/NumberInput/NumberInputView";
|
|
||||||
import Beat, { BeatEvents } from "@/Beat";
|
|
||||||
import BoolBoxView from "@/ui/Widgets/BoolBox/BoolBoxView";
|
|
||||||
import TrackSettingsView from "@/ui/TrackSettings/TrackSettingsView";
|
|
||||||
import ActionButtonView from "@/ui/Widgets/ActionButton/ActionButtonView";
|
|
||||||
import { h, ISubscriber, Rung, RungOptions } from "@djledda/ladder";
|
|
||||||
|
|
||||||
export type BeatSettingsUINodeOptions = RungOptions & {
|
|
||||||
beat: Beat,
|
|
||||||
};
|
|
||||||
|
|
||||||
const EventTypeSubscriptions = [
|
|
||||||
BeatEvents.TimeSigUpChanged,
|
|
||||||
BeatEvents.BarCountChanged,
|
|
||||||
BeatEvents.GlobalDisplayTypeChanged,
|
|
||||||
BeatEvents.TrackListChanged,
|
|
||||||
BeatEvents.LockingChanged,
|
|
||||||
BeatEvents.AutoBeatSettingsChanged,
|
|
||||||
];
|
|
||||||
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
|
|
||||||
|
|
||||||
export default class BeatSettingsView extends Rung implements ISubscriber<EventTypeSubscriptions> {
|
|
||||||
private beat: Beat;
|
|
||||||
private barCountInput!: NumberInputView;
|
|
||||||
private timeSigUpInput!: NumberInputView;
|
|
||||||
private autoBeatLengthCheckbox!: BoolBoxView;
|
|
||||||
private trackSettingsViews: TrackSettingsView[] = [];
|
|
||||||
private trackSettingsContainer!: HTMLDivElement;
|
|
||||||
|
|
||||||
constructor(options: BeatSettingsUINodeOptions) {
|
|
||||||
super(options);
|
|
||||||
this.beat = options.beat;
|
|
||||||
this.setupBindings();
|
|
||||||
}
|
|
||||||
|
|
||||||
setBeat(newBeat: Beat): void {
|
|
||||||
this.beat = newBeat;
|
|
||||||
this.setupBindings();
|
|
||||||
EventTypeSubscriptions.forEach(eventType => this.notify(null, eventType));
|
|
||||||
}
|
|
||||||
|
|
||||||
setupBindings(): void {
|
|
||||||
this.beat.addSubscriber(this, EventTypeSubscriptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
notify(publisher: unknown, event: EventTypeSubscriptions): void {
|
|
||||||
switch(event) {
|
|
||||||
case BeatEvents.BarCountChanged:
|
|
||||||
this.barCountInput.setValue(this.beat.getBarCount());
|
|
||||||
break;
|
|
||||||
case BeatEvents.TimeSigUpChanged:
|
|
||||||
this.timeSigUpInput.setValue(this.beat.getTimeSigUp());
|
|
||||||
break;
|
|
||||||
case BeatEvents.TrackListChanged:
|
|
||||||
this.remakeBeatSettingsViews();
|
|
||||||
break;
|
|
||||||
case BeatEvents.LockingChanged:
|
|
||||||
if (this.beat.barsLocked()) {
|
|
||||||
this.barCountInput.disable();
|
|
||||||
} else {
|
|
||||||
this.barCountInput.enable();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case BeatEvents.AutoBeatSettingsChanged:
|
|
||||||
this.autoBeatLengthCheckbox.setValue(this.beat.autoBeatLengthOn());
|
|
||||||
break;
|
|
||||||
case BeatEvents.GlobalDisplayTypeChanged:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private remakeBeatSettingsViews() {
|
|
||||||
const trackCount = this.beat.getTrackCount();
|
|
||||||
this.trackSettingsViews.splice(trackCount, this.trackSettingsViews.length - trackCount);
|
|
||||||
for (let i = 0; i < trackCount; i++) {
|
|
||||||
if (this.trackSettingsViews[i]) {
|
|
||||||
this.trackSettingsViews[i].setBeat(this.beat.getTrackByIndex(i));
|
|
||||||
} else {
|
|
||||||
this.trackSettingsViews.unshift(new TrackSettingsView({ track: this.beat.getTrackByIndex(i) }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!this.trackSettingsContainer) {
|
|
||||||
this.trackSettingsContainer = <div>{...this.trackSettingsViews}</div> as HTMLDivElement;
|
|
||||||
} else {
|
|
||||||
this.trackSettingsContainer.replaceChildren(...this.trackSettingsViews.reverse().map(view => view.render()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
build(): Node {
|
|
||||||
this.barCountInput = new NumberInputView({
|
|
||||||
label: "Bars:",
|
|
||||||
initialValue: this.beat.getBarCount(),
|
|
||||||
setter: (input: number) => this.beat.setBarCount(input),
|
|
||||||
getter: () => this.beat.getBarCount(),
|
|
||||||
});
|
|
||||||
this.timeSigUpInput = new NumberInputView({
|
|
||||||
label: "Boxes per bar:",
|
|
||||||
initialValue: this.beat.getTimeSigUp(),
|
|
||||||
setter: (input: number) => this.beat.setTimeSigUp(input),
|
|
||||||
getter: () => this.beat.getTimeSigUp(),
|
|
||||||
});
|
|
||||||
this.autoBeatLengthCheckbox = new BoolBoxView({
|
|
||||||
label: "Auto beat length:",
|
|
||||||
value: this.beat.autoBeatLengthOn(),
|
|
||||||
onInput: (isChecked: boolean) => this.beat.setIsUsingAutoBeatLength(isChecked),
|
|
||||||
});
|
|
||||||
this.remakeBeatSettingsViews();
|
|
||||||
return <div className={"beat-settings"}>
|
|
||||||
<div className={"beat-settings-options"}>
|
|
||||||
<div classes={["beat-settings-boxes", "beat-settings-option"]}>
|
|
||||||
{this.timeSigUpInput}
|
|
||||||
</div>
|
|
||||||
<div classes={["beat-settings-bar-count", "beat-settings-option"]}>
|
|
||||||
{this.barCountInput}
|
|
||||||
</div>
|
|
||||||
<div classes={["beat-settings-bar-count", "beat-settings-option"]}>
|
|
||||||
{this.autoBeatLengthCheckbox}
|
|
||||||
</div>
|
|
||||||
{new ActionButtonView({
|
|
||||||
label: "New Track",
|
|
||||||
onClick: () => this.beat.addTrack(),
|
|
||||||
})}
|
|
||||||
{this.trackSettingsContainer}
|
|
||||||
</div>
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
8
src/ui/BeatSummary/BeatSummarySettings.vue
Normal file
8
src/ui/BeatSummary/BeatSummarySettings.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
</style>
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
:root {
|
|
||||||
--color-ui-accent: #00b3ba;
|
|
||||||
--color-ui-accent-hover: #00c1c9;
|
|
||||||
--color-ui-accent-active: #008e93;
|
|
||||||
--color-ui-neutral-light: #fdfdfe;
|
|
||||||
--color-ui-neutral-light-hover: #fdfdfe;
|
|
||||||
--color-ui-neutral-light-active: #fdfdfe;
|
|
||||||
--color-ui-neutral-dark: #8b8b8b;
|
|
||||||
--color-ui-neutral-dark-hover: #a1a1a1;
|
|
||||||
--color-ui-neutral-dark-active: #c1c1c1;
|
|
||||||
--color-bg-light: #464646;
|
|
||||||
--color-bg-medium: #323232;
|
|
||||||
--color-bg-dark: #282828;
|
|
||||||
--color-p-light: #fafafa;
|
|
||||||
--color-p-light-hover: #fafafa;
|
|
||||||
--color-p-light-active: #fafafa;
|
|
||||||
--color-p-dark: #282828;
|
|
||||||
--color-p-dark-hover: #464646;
|
|
||||||
--color-p-dark-active: #464646;
|
|
||||||
--color-title-light: #fafafa;
|
|
||||||
--color-title-dark: #282828;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--color-p-light);
|
|
||||||
background-color: var(--color-bg-dark);
|
|
||||||
height: 100vh;
|
|
||||||
align-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root-sidebar {
|
|
||||||
position: absolute;
|
|
||||||
left: -28em;
|
|
||||||
width: 30em;
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
transition: left 400ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-visible .root-sidebar {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root-settings {
|
|
||||||
z-index: 1;
|
|
||||||
width: 28em;
|
|
||||||
background-color: var(--color-bg-light);
|
|
||||||
overflow: scroll;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root-settings .root-title {
|
|
||||||
color: var(--color-title-light);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root-sidebar-toggle {
|
|
||||||
z-index: 1;
|
|
||||||
height: 100vh;
|
|
||||||
min-width: 2em;
|
|
||||||
background-color: var(--color-bg-light);
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root-quick-access-button {
|
|
||||||
right: 0;
|
|
||||||
width: 2em;
|
|
||||||
height: 2em;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root-beat-stage-container {
|
|
||||||
position: absolute;
|
|
||||||
height: 100%;
|
|
||||||
left: 0;
|
|
||||||
width: 100vw;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
transition: left 400ms, width 400ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-visible .root-beat-stage-container {
|
|
||||||
left: 30em;
|
|
||||||
width: calc(100vw - 30em);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root-beat-stage {
|
|
||||||
position: relative;
|
|
||||||
max-height: 100vh;
|
|
||||||
margin: auto;
|
|
||||||
max-width: 100vw;
|
|
||||||
transition: max-width 400ms;
|
|
||||||
padding-left: 3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vertical-mode .root-beat-stage {
|
|
||||||
margin: auto auto;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-visible .root-beat-stage {
|
|
||||||
max-width: calc(100vw - 30em);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root-sidebar-left-strip {
|
|
||||||
writing-mode: vertical-rl;
|
|
||||||
background-color: var(--color-bg-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root-sidebar-left-strip > * {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root-sidebar-left-tab {
|
|
||||||
transform: rotate(-180deg);
|
|
||||||
display: inline-block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 3px 8px 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root-sidebar-left-tab.active {
|
|
||||||
background-color: var(--color-bg-medium);
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root-sidebar-add-beat {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 3px 8px 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root-sidebar-add-beat:hover,
|
|
||||||
.root-sidebar-left-tab:hover:not(.active) {
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: var(--color-ui-neutral-dark);
|
|
||||||
transition: background-color 200ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 900px) {
|
|
||||||
.sidebar-visible .root-sidebar {
|
|
||||||
left: 0;
|
|
||||||
width: 100vw;
|
|
||||||
}
|
|
||||||
.root-sidebar {
|
|
||||||
left: calc(-100vw + 2em);
|
|
||||||
width: 100vw;
|
|
||||||
}
|
|
||||||
.root-settings {
|
|
||||||
width: calc(100vw - 2em);
|
|
||||||
}
|
|
||||||
.sidebar-visible .root-beat-stage-container {
|
|
||||||
left: 100vw;
|
|
||||||
}
|
|
||||||
.root-beat-stage-container {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
.sidebar-visible .root-beat-stage {
|
|
||||||
max-width: 100vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
user-drag: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
268
src/ui/Root/Root.vue
Normal file
268
src/ui/Root/Root.vue
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="root"
|
||||||
|
:class="{ 'sidebar-visible': sidebarActive, 'vertical-mode': currentOrientation === 'vertical' }">
|
||||||
|
<div class="root-sidebar">
|
||||||
|
<div class="root-sidebar-left-strip">
|
||||||
|
<div v-for="(beat, i) in beats"
|
||||||
|
:key="beat.name.value"
|
||||||
|
class="root-sidebar-left-tab"
|
||||||
|
:class="{ 'active': i === activeBeatIndex }"
|
||||||
|
@click="activeBeatIndex = i">
|
||||||
|
{{ beat.name.value }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="root-sidebar-add-beat"
|
||||||
|
@click="addNewBeat()">
|
||||||
|
+
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="root-settings">
|
||||||
|
<h1 class="root-title">{{ title }}</h1>
|
||||||
|
<beat-settings :beat-index="activeBeatIndex" />
|
||||||
|
</div>
|
||||||
|
<div class="root-sidebar-toggle">
|
||||||
|
<div
|
||||||
|
class="root-quick-access-button"
|
||||||
|
:title="`${ sidebarActive ? 'Hide' : 'Show' } sidebar`"
|
||||||
|
@click="sidebarActive = !sidebarActive">
|
||||||
|
<icon icon-name="list" color="var(--color-ui-neutral-dark)" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="root-quick-access-button"
|
||||||
|
title="Change orientation"
|
||||||
|
@click="toggleOrientation">
|
||||||
|
<icon icon-name="arrowClockwise" color="var(--color-ui-neutral-dark)" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="root-quick-access-button"
|
||||||
|
title="Bake all tracks"
|
||||||
|
@click="bakeAll">
|
||||||
|
<icon icon-name="snowflake" color="var(--color-ui-neutral-dark)" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="root-quick-access-button"
|
||||||
|
title="Reset all"
|
||||||
|
@click="resetActiveBeat">
|
||||||
|
<icon icon-name="trash" color="var(--color-ui-neutral-dark)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="root-beat-stage-container">
|
||||||
|
<toolbox />
|
||||||
|
<div class="root-beat-stage">
|
||||||
|
<beat-view
|
||||||
|
:beat-index="activeBeatIndex"
|
||||||
|
:orientation="currentOrientation" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, provide, ref } from "vue";
|
||||||
|
import BeatSettings from "@/ui/BeatSettings/BeatSettings.vue";
|
||||||
|
import BeatView from "@/ui/Beat/Beat.vue";
|
||||||
|
import Icon from "@/ui/Widgets/Icon/Icon.vue";
|
||||||
|
import Toolbox from "@/ui/Root/Toolbox.vue";
|
||||||
|
import { BeatStoreKey, createBeatStore } from "@/BeatStore";
|
||||||
|
import { AppStateStoreKey, createAppStateStore } from "@/AppState";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
title: string,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const currentOrientation = ref<'horizontal' | 'vertical'>('horizontal');
|
||||||
|
const sidebarActive = ref(false);
|
||||||
|
|
||||||
|
const appStateStore = createAppStateStore();
|
||||||
|
provide(AppStateStoreKey, appStateStore);
|
||||||
|
|
||||||
|
const beatStore = createBeatStore();
|
||||||
|
provide(BeatStoreKey, beatStore);
|
||||||
|
|
||||||
|
const {
|
||||||
|
resetActiveBeat,
|
||||||
|
activeBeatIndex,
|
||||||
|
beats,
|
||||||
|
addNewBeat,
|
||||||
|
bakeAll,
|
||||||
|
} = beatStore;
|
||||||
|
|
||||||
|
const mediaQueryList = window.matchMedia("screen and (max-width: 900px)");
|
||||||
|
const onMediaChange = (event: MediaQueryListEvent | MediaQueryList) => {
|
||||||
|
sidebarActive.value = event.matches;
|
||||||
|
};
|
||||||
|
mediaQueryList.addEventListener('change', onMediaChange);
|
||||||
|
onMediaChange(mediaQueryList);
|
||||||
|
|
||||||
|
function windowMouseUp() {
|
||||||
|
appStateStore.selectingUnits.value = false;
|
||||||
|
appStateStore.deselectingUnits.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('mouseup', windowMouseUp);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('mouseup', windowMouseUp);
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleOrientation(): void {
|
||||||
|
if (currentOrientation.value === "vertical") {
|
||||||
|
currentOrientation.value = "horizontal";
|
||||||
|
} else {
|
||||||
|
currentOrientation.value = "vertical";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-p-light);
|
||||||
|
background-color: var(--color-bg-dark);
|
||||||
|
height: 100vh;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-sidebar {
|
||||||
|
position: absolute;
|
||||||
|
left: -28em;
|
||||||
|
width: 30em;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
transition: left 400ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-visible .root-sidebar {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-settings {
|
||||||
|
z-index: 1;
|
||||||
|
width: 28em;
|
||||||
|
background-color: var(--color-bg-light);
|
||||||
|
overflow: scroll;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-settings .root-title {
|
||||||
|
color: var(--color-title-light);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-sidebar-toggle {
|
||||||
|
z-index: 1;
|
||||||
|
height: 100vh;
|
||||||
|
min-width: 2em;
|
||||||
|
background-color: var(--color-bg-light);
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-quick-access-button {
|
||||||
|
right: 0;
|
||||||
|
width: 2em;
|
||||||
|
height: 2em;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-beat-stage-container {
|
||||||
|
position: absolute;
|
||||||
|
height: 100%;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: left 400ms, width 400ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-visible .root-beat-stage-container {
|
||||||
|
left: 30em;
|
||||||
|
width: calc(100vw - 30em);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-beat-stage {
|
||||||
|
position: relative;
|
||||||
|
max-height: 100vh;
|
||||||
|
margin: auto;
|
||||||
|
max-width: 100vw;
|
||||||
|
transition: max-width 400ms;
|
||||||
|
padding-left: 3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-mode .root-beat-stage {
|
||||||
|
margin: auto auto;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-visible .root-beat-stage {
|
||||||
|
max-width: calc(100vw - 30em);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-sidebar-left-strip {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
background-color: var(--color-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-sidebar-left-strip > * {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-sidebar-left-tab {
|
||||||
|
transform: rotate(-180deg);
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 3px 8px 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-sidebar-left-tab.active {
|
||||||
|
background-color: var(--color-bg-medium);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-sidebar-add-beat {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 3px 8px 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-sidebar-add-beat:hover,
|
||||||
|
.root-sidebar-left-tab:hover:not(.active) {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--color-ui-neutral-dark);
|
||||||
|
transition: background-color 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 900px) {
|
||||||
|
.sidebar-visible .root-sidebar {
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
.root-sidebar {
|
||||||
|
left: calc(-100vw + 2em);
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
.root-settings {
|
||||||
|
width: calc(100vw - 2em);
|
||||||
|
}
|
||||||
|
.sidebar-visible .root-beat-stage-container {
|
||||||
|
left: 100vw;
|
||||||
|
}
|
||||||
|
.root-beat-stage-container {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.sidebar-visible .root-beat-stage {
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
user-drag: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
import BeatView from "@/ui/Beat/BeatView";
|
|
||||||
import Beat from "@/Beat";
|
|
||||||
import "./Root.css";
|
|
||||||
import BeatSettingsView from "@/ui/BeatSettings/BeatSettingsView";
|
|
||||||
import IconView from "@/ui/Widgets/Icon/IconView";
|
|
||||||
import BeatStore from "@/BeatStore";
|
|
||||||
import { Capsule, h, frag, Rung, RungOptions, ICapsule, ISubscriber } from "@djledda/ladder";
|
|
||||||
import AppState from "@/AppState";
|
|
||||||
import ToolboxView from "@/ui/Root/ToolboxView";
|
|
||||||
|
|
||||||
export type RootUINodeOptions = RungOptions & {
|
|
||||||
title: string,
|
|
||||||
mainBeat?: Beat,
|
|
||||||
orientation?: "horizontal" | "vertical",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class RootView extends Rung<HTMLDivElement> {
|
|
||||||
private title: string;
|
|
||||||
private beatView: BeatView;
|
|
||||||
private beatStore: BeatStore;
|
|
||||||
private activeBeat: ICapsule<Beat>;
|
|
||||||
private beatSettingsView: BeatSettingsView;
|
|
||||||
private currentOrientation: "horizontal" | "vertical";
|
|
||||||
private showHideSidebarButton = Capsule.new<HTMLDivElement | null>(null);
|
|
||||||
private sidebarActive = true;
|
|
||||||
private sidebarLeftTabs = Capsule.new<HTMLDivElement | null>(null);
|
|
||||||
private state = new AppState();
|
|
||||||
private toolboxView: ToolboxView;
|
|
||||||
|
|
||||||
constructor(options: RootUINodeOptions) {
|
|
||||||
super(options);
|
|
||||||
this.beatStore = new BeatStore({
|
|
||||||
loadFromLocalStorage: true,
|
|
||||||
autoSave: true,
|
|
||||||
});
|
|
||||||
this.toolboxView = new ToolboxView({ state: this.state });
|
|
||||||
this.activeBeat = this.beatStore.getActiveBeat();
|
|
||||||
this.activeBeat.watch((newVal) => {
|
|
||||||
this.beatSettingsView.setBeat(newVal);
|
|
||||||
this.beatView.setBeat(newVal);
|
|
||||||
});
|
|
||||||
this.currentOrientation = this.beatStore.getSavedOrientation() ?? options.orientation ?? "horizontal";
|
|
||||||
this.beatView = new BeatView({
|
|
||||||
state: this.state,
|
|
||||||
beat: this.activeBeat.val,
|
|
||||||
orientation: this.currentOrientation,
|
|
||||||
});
|
|
||||||
this.beatStore.onBeatChanges(() => {
|
|
||||||
this.sidebarLeftTabs.val?.replaceChildren(<this.Tabs />);
|
|
||||||
});
|
|
||||||
this.beatSettingsView = new BeatSettingsView({ beat: this.activeBeat.val });
|
|
||||||
this.title = options.title;
|
|
||||||
this.setOrientation(this.currentOrientation);
|
|
||||||
this.openSidebarForDesktop();
|
|
||||||
}
|
|
||||||
|
|
||||||
private openSidebarForDesktop() {
|
|
||||||
const mediaQueryList = window.matchMedia("screen and (max-width: 900px)");
|
|
||||||
if (mediaQueryList.matches) {
|
|
||||||
this.toggleSidebar();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleSidebar(): void {
|
|
||||||
this.sidebarActive = !this.sidebarActive;
|
|
||||||
if (this.showHideSidebarButton.val) {
|
|
||||||
this.showHideSidebarButton.val.title = this.sidebarText();
|
|
||||||
}
|
|
||||||
this.render().classList.toggle("sidebar-visible");
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleOrientation(): void {
|
|
||||||
if (this.currentOrientation === "vertical") {
|
|
||||||
this.setOrientation("horizontal");
|
|
||||||
} else {
|
|
||||||
this.setOrientation("vertical");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setOrientation(orientation: "horizontal" | "vertical"): void {
|
|
||||||
this.currentOrientation = orientation;
|
|
||||||
if (orientation === "vertical") {
|
|
||||||
this.render().classList.add("vertical-mode");
|
|
||||||
} else {
|
|
||||||
this.render().classList.remove("vertical-mode");
|
|
||||||
}
|
|
||||||
this.beatStore.setOrientation(orientation);
|
|
||||||
this.beatView.setOrientation(orientation);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sidebarText(): string {
|
|
||||||
return `${this.sidebarActive ? "Hide" : "Show"} sidebar`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private SidebarStripLeft = (): HTMLElement => {
|
|
||||||
return <div className={"root-sidebar-left-strip"}>
|
|
||||||
<div saveTo={this.sidebarLeftTabs}>
|
|
||||||
<this.Tabs />
|
|
||||||
</div>
|
|
||||||
<div className={"root-sidebar-add-beat"} onclick={() => this.beatStore.addNewBeat()}>+</div>
|
|
||||||
</div> as HTMLElement;
|
|
||||||
};
|
|
||||||
|
|
||||||
private Tabs = (): Node => {
|
|
||||||
return <div>
|
|
||||||
{...this.beatStore.getBeats().map((beat) => {
|
|
||||||
const node = <div
|
|
||||||
className={"root-sidebar-left-tab" + (beat === this.activeBeat.val ? " active" : "")}
|
|
||||||
onclick={() => this.beatStore.setActiveBeat(beat)}>
|
|
||||||
{beat.getName()}
|
|
||||||
</div> as HTMLDivElement;
|
|
||||||
this.activeBeat.watch((newVal) => {
|
|
||||||
if (beat === newVal) {
|
|
||||||
node.classList.add("active");
|
|
||||||
} else {
|
|
||||||
node.classList.remove("active");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return node;
|
|
||||||
})}
|
|
||||||
</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
private SidebarQuickButtons = (): HTMLElement => {
|
|
||||||
return <div className={"root-sidebar-toggle"}>
|
|
||||||
<div
|
|
||||||
className={"root-quick-access-button"}
|
|
||||||
title={this.sidebarText()}
|
|
||||||
saveTo={this.showHideSidebarButton}
|
|
||||||
onclick={() => this.toggleSidebar()}>
|
|
||||||
{new IconView({
|
|
||||||
iconName: "list",
|
|
||||||
color: "var(--color-ui-neutral-dark)"
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={"root-quick-access-button"}
|
|
||||||
title={"Change orientation"}
|
|
||||||
onclick={() => this.toggleOrientation()}>
|
|
||||||
{new IconView({
|
|
||||||
iconName: "arrowClockwise",
|
|
||||||
color: "var(--color-ui-neutral-dark)"
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={"root-quick-access-button"}
|
|
||||||
title={"Bake all tracks"}
|
|
||||||
onclick={() => this.activeBeat.val.bakeLoops()}>
|
|
||||||
{new IconView({
|
|
||||||
iconName: "snowflake",
|
|
||||||
color: "var(--color-ui-neutral-dark)"
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={"root-quick-access-button"}
|
|
||||||
title={"Reset all"}
|
|
||||||
onclick={() => this.beatStore.resetActiveBeat()}>
|
|
||||||
{new IconView({
|
|
||||||
iconName: "trash",
|
|
||||||
color: "var(--color-ui-neutral-dark)"
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div> as HTMLElement;
|
|
||||||
};
|
|
||||||
|
|
||||||
private Sidebar = (): HTMLElement => {
|
|
||||||
return <div className={"root-sidebar"}>
|
|
||||||
<this.SidebarStripLeft />
|
|
||||||
<div
|
|
||||||
className={"root-settings"}>
|
|
||||||
<h1 className={"root-title"}>{this.title}</h1>
|
|
||||||
{this.beatSettingsView}
|
|
||||||
</div>
|
|
||||||
<this.SidebarQuickButtons />
|
|
||||||
</div> as HTMLElement;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
build() {
|
|
||||||
return (
|
|
||||||
<div classes={["root", "sidebar-visible"]}>
|
|
||||||
<this.Sidebar/>
|
|
||||||
<div className={"root-beat-stage-container"}>
|
|
||||||
{this.toolboxView}
|
|
||||||
<div className={"root-beat-stage"}>
|
|
||||||
{this.beatView}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) as HTMLDivElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
.root-toolbox { }
|
|
||||||
|
|
||||||
.root-toolbox .main-row {
|
|
||||||
height: 2.5em;
|
|
||||||
margin: auto;
|
|
||||||
display: flex;
|
|
||||||
background-color: var(--color-ui-neutral-dark);
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root-toolbox .details {
|
|
||||||
margin: auto;
|
|
||||||
height: 4em;
|
|
||||||
width: min-content;
|
|
||||||
border-radius: 0 0 1em 1em;
|
|
||||||
padding: 0.5em;
|
|
||||||
background-color: var(--color-ui-neutral-dark-active);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root-toolbox .details.hidden {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root-toolbox .track-unit {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root-toolbox .toolbox-button {
|
|
||||||
padding: 0.5em;
|
|
||||||
cursor: pointer;
|
|
||||||
color: black;
|
|
||||||
background-color: var(--color-ui-neutral-dark);
|
|
||||||
}
|
|
||||||
.root-toolbox .toolbox-button:hover {
|
|
||||||
background-color: var(--color-ui-neutral-dark-hover);
|
|
||||||
}
|
|
||||||
.root-toolbox .toolbox-button.active {
|
|
||||||
background-color: var(--color-ui-neutral-dark-active);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root-toolbox .details .toolbox-button.active {
|
|
||||||
background-color: var(--color-ui-neutral-dark);
|
|
||||||
}
|
|
||||||
.root-toolbox .details .toolbox-button:hover {
|
|
||||||
background-color: var(--color-ui-neutral-dark-hover);
|
|
||||||
}
|
|
||||||
.root-toolbox .details .toolbox-button {
|
|
||||||
background-color: var(--color-ui-neutral-dark-active);
|
|
||||||
}
|
|
||||||
114
src/ui/Root/Toolbox.vue
Normal file
114
src/ui/Root/Toolbox.vue
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<div class="root-toolbox">
|
||||||
|
<div class="main-row">
|
||||||
|
<div
|
||||||
|
class="toolbox-button"
|
||||||
|
:class="{ active: selectedTool === 'track-unit-type' }"
|
||||||
|
@click="selectedTool = 'track-unit-type'">
|
||||||
|
Track Type
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="toolbox-button"
|
||||||
|
:class="{ active: selectedTool === 'sticking' }"
|
||||||
|
@click="selectedTool = 'sticking'">
|
||||||
|
Sticking
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="toolbox-button"
|
||||||
|
:class="{ active: selectedTool === 'eraser' }"
|
||||||
|
@click="selectedTool = 'eraser'">
|
||||||
|
Eraser
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedTool === 'track-unit-type'" class="details">
|
||||||
|
<div v-for="(type, i) in TrackUnitTypeList"
|
||||||
|
:key="type"
|
||||||
|
class="toolbox-button"
|
||||||
|
:class="{ active: i === activeTrackUnitType }"
|
||||||
|
@click="activeTrackUnitType = i">
|
||||||
|
<div :class="getClasses({ on: true, type, stickingType: 'none' })" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="selectedTool === 'sticking'" class="details">
|
||||||
|
<div v-for="(stickingType, i) in TrackUnitStickingTypeList.slice(1)"
|
||||||
|
:key="stickingType"
|
||||||
|
class="toolbox-button"
|
||||||
|
:class="{ active: i + 1 === activeStickingType }"
|
||||||
|
@click="activeStickingType = i + 1">
|
||||||
|
<icon :icon-name="StickingTypeIconMap[stickingType]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="selectedTool === 'eraser'" class="details hidden" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAppStateStore } from "@/AppState";
|
||||||
|
import { TrackUnitStickingTypeList, TrackUnitTypeList } from "@/Track";
|
||||||
|
import { StickingTypeIconMap } from "@/ui/TrackUnit/trackUnit";
|
||||||
|
import Icon from "@/ui/Widgets/Icon/Icon.vue";
|
||||||
|
import { getClasses } from "@/ui/TrackUnit/trackUnit";
|
||||||
|
|
||||||
|
const {
|
||||||
|
selectedTool,
|
||||||
|
activeTrackUnitType,
|
||||||
|
activeStickingType
|
||||||
|
} = useAppStateStore();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.root-toolbox { }
|
||||||
|
|
||||||
|
.root-toolbox .main-row {
|
||||||
|
height: 2.5em;
|
||||||
|
margin: auto;
|
||||||
|
display: flex;
|
||||||
|
background-color: var(--color-ui-neutral-dark);
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-toolbox .details {
|
||||||
|
margin: auto;
|
||||||
|
height: 4em;
|
||||||
|
width: min-content;
|
||||||
|
border-radius: 0 0 1em 1em;
|
||||||
|
padding: 0.5em;
|
||||||
|
background-color: var(--color-ui-neutral-dark-active);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-toolbox .details.hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-toolbox .track-unit {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-toolbox .toolbox-button {
|
||||||
|
padding: 0.5em;
|
||||||
|
cursor: pointer;
|
||||||
|
color: black;
|
||||||
|
background-color: var(--color-ui-neutral-dark);
|
||||||
|
}
|
||||||
|
.root-toolbox .toolbox-button:hover {
|
||||||
|
background-color: var(--color-ui-neutral-dark-hover);
|
||||||
|
}
|
||||||
|
.root-toolbox .toolbox-button.active {
|
||||||
|
background-color: var(--color-ui-neutral-dark-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-toolbox .details .toolbox-button.active {
|
||||||
|
background-color: var(--color-ui-neutral-dark);
|
||||||
|
}
|
||||||
|
.root-toolbox .details .toolbox-button:hover {
|
||||||
|
background-color: var(--color-ui-neutral-dark-hover);
|
||||||
|
}
|
||||||
|
.root-toolbox .details .toolbox-button {
|
||||||
|
background-color: var(--color-ui-neutral-dark-active);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import AppState, { AppStateEvent } from "@/AppState";
|
|
||||||
import { TrackUnitStickingTypeList, TrackUnitTypeList } from "@/TrackUnit";
|
|
||||||
import { Capsule, h, frag, Rung, RungOptions, ISubscriber } from "@djledda/ladder";
|
|
||||||
import TrackUnitView, { StickingTypeIconMap } from "../TrackUnit/TrackUnitView";
|
|
||||||
import IconView from "../Widgets/Icon/IconView";
|
|
||||||
import "./Toolbox.css";
|
|
||||||
|
|
||||||
type ToolboxOptions = RungOptions & {
|
|
||||||
state: AppState,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class ToolboxView extends Rung<HTMLDivElement> implements ISubscriber<AppStateEvent> {
|
|
||||||
private state: AppState;
|
|
||||||
|
|
||||||
constructor(options: ToolboxOptions) {
|
|
||||||
super(options);
|
|
||||||
this.state = options.state;
|
|
||||||
this.state.addSubscriber(this, 'appstate-tool-select');
|
|
||||||
}
|
|
||||||
|
|
||||||
notify(publisher: unknown, event: AppStateEvent): void {
|
|
||||||
if (event === 'appstate-tool-select') {
|
|
||||||
this.redraw();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private SubMenuButtons = () => {
|
|
||||||
switch (this.state.selectedTool) {
|
|
||||||
case "track-unit-type":
|
|
||||||
return <div className={"details"}>{...TrackUnitTypeList.map(type => (
|
|
||||||
<div
|
|
||||||
className={`toolbox-button${type === this.state.activeTrackUnitType ? " active" : ""}`}
|
|
||||||
onclick={() => this.state.selectTrackUnitTypePaint(type)}>
|
|
||||||
<div classes={TrackUnitView.getClasses({ on: true, type, stickingType: "none" })} />
|
|
||||||
</div>
|
|
||||||
))}</div>;
|
|
||||||
case "sticking":
|
|
||||||
return <div className={"details"}>{...TrackUnitStickingTypeList.reduce((prev, stickingType) => {
|
|
||||||
if (stickingType !== "none") {
|
|
||||||
prev.push(<div
|
|
||||||
className={`toolbox-button${stickingType === this.state.activeStickingType ? " active" : ""}`}
|
|
||||||
onclick={() => this.state.selectStickingTypePaint(stickingType)}>
|
|
||||||
{new IconView({ iconName: StickingTypeIconMap[stickingType] })}
|
|
||||||
</div> as HTMLButtonElement);
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
}, [] as HTMLElement[])}</div>;
|
|
||||||
case "eraser":
|
|
||||||
return <div className={"details hidden"} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected build() {
|
|
||||||
return <div className={"root-toolbox"}>
|
|
||||||
<div className={"main-row"}>
|
|
||||||
<div
|
|
||||||
className={`toolbox-button${this.state.selectedTool === "track-unit-type" ? " active" : ""}`}
|
|
||||||
onclick={() => this.state.selectTool("track-unit-type")}>
|
|
||||||
Track Type
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`toolbox-button${this.state.selectedTool === "sticking" ? " active" : ""}`}
|
|
||||||
onclick={() => this.state.selectTool("sticking")}>
|
|
||||||
Sticking
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`toolbox-button${this.state.selectedTool === "eraser" ? " active" : ""}`}
|
|
||||||
onclick={() => this.state.selectTool("eraser")}>
|
|
||||||
Eraser
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<this.SubMenuButtons />
|
|
||||||
</div> as HTMLDivElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
.vertical-mode .track {
|
|
||||||
height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vertical-mode .track {
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track > * {
|
|
||||||
padding-right: 1em;
|
|
||||||
padding-left: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vertical-mode .track > * {
|
|
||||||
padding-right: 0;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-unit-block {
|
|
||||||
height: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vertical-mode .track-unit-block {
|
|
||||||
height: auto;
|
|
||||||
width: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-spacer {
|
|
||||||
display: inline-block;
|
|
||||||
width: 1em;
|
|
||||||
height: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vertical-mode .track-spacer {
|
|
||||||
display: block;
|
|
||||||
width: 2em;
|
|
||||||
height: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-main {
|
|
||||||
height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vertical-mode .track-main {
|
|
||||||
width: 2em;
|
|
||||||
margin-right: 4px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-settings-container {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track {
|
|
||||||
width: max-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vertical-mode .track {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
152
src/ui/Track/Track.vue
Normal file
152
src/ui/Track/Track.vue
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<template>
|
||||||
|
<div class="track">
|
||||||
|
<div class="track-main">
|
||||||
|
<div class="track-unit-block">
|
||||||
|
<track-unit v-for="(trackUnit, i) in trackUnits"
|
||||||
|
class="track-unit"
|
||||||
|
:class="{ spaced: (i + 1) % beat!.timeSigUp.value === 0 }"
|
||||||
|
:sticking-type="trackUnit.stickingType"
|
||||||
|
:type="trackUnit.type"
|
||||||
|
:on="trackUnit.on"
|
||||||
|
@rotate-type="rotateTrackUnit(i)"
|
||||||
|
@mouseup="applyCurrentToolToTrackUnit(i)"
|
||||||
|
@mousedown="applyCurrentToolToTrackUnit(i)"
|
||||||
|
@mouseover="applyCurrentToolToTrackUnit(i)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import TrackUnit from "@/ui/TrackUnit/TrackUnit.vue";
|
||||||
|
import { useBeatStore } from "@/BeatStore";
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useAppStateStore } from "@/AppState";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
beatIndex: number,
|
||||||
|
trackIndex: number,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const {
|
||||||
|
selectedTool,
|
||||||
|
activeStickingType,
|
||||||
|
activeTrackUnitType,
|
||||||
|
selectingUnits,
|
||||||
|
deselectingUnits,
|
||||||
|
} = useAppStateStore();
|
||||||
|
const { beats } = useBeatStore();
|
||||||
|
const beat = computed(() => beats.value[props.beatIndex] ?? null);
|
||||||
|
const track = computed(() => beat.value?.tracks.value[props.trackIndex] ?? null);
|
||||||
|
const title = computed(() => track.value?.name);
|
||||||
|
|
||||||
|
const trackUnits = computed(() => {
|
||||||
|
const units = [];
|
||||||
|
if (track.value) {
|
||||||
|
for (let i = 0; i < track.value.unitRecord.value.length; i++) {
|
||||||
|
const unit = track.value.getUnitByIndex(i);
|
||||||
|
if (unit) {
|
||||||
|
units.push(unit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return units;
|
||||||
|
});
|
||||||
|
|
||||||
|
function rotateTrackUnit(index: number) {
|
||||||
|
track.value?.rotateUnit(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCurrentToolToTrackUnit(index: number) {
|
||||||
|
if (selectedTool.value === "sticking") {
|
||||||
|
if (selectingUnits.value) {
|
||||||
|
track.value?.setStickingType(index, activeStickingType.value);
|
||||||
|
} else if (deselectingUnits.value) {
|
||||||
|
track.value?.setStickingType(index, 0);
|
||||||
|
}
|
||||||
|
} else if (selectedTool.value === "track-unit-type") {
|
||||||
|
if (selectingUnits.value) {
|
||||||
|
track.value?.updateUnit(index, { on: true, type: activeTrackUnitType.value });
|
||||||
|
} else if (deselectingUnits.value) {
|
||||||
|
track.value?.setUnitOn(index, false);
|
||||||
|
}
|
||||||
|
} else if (selectedTool.value === "eraser") {
|
||||||
|
if (selectingUnits.value || deselectingUnits.value) {
|
||||||
|
track.value?.setUnitOn(index, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.vertical-mode .track {
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-mode .track {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track > * {
|
||||||
|
padding-right: 1em;
|
||||||
|
padding-left: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-mode .track-unit.spaced {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-unit.spaced {
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-mode .track > * {
|
||||||
|
padding-right: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-unit-block {
|
||||||
|
height: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-mode .track-unit-block {
|
||||||
|
height: auto;
|
||||||
|
width: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-spacer {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1em;
|
||||||
|
height: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-mode .track-spacer {
|
||||||
|
display: block;
|
||||||
|
width: 2em;
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-main {
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-mode .track-main {
|
||||||
|
width: 2em;
|
||||||
|
margin-right: 4px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-settings-container {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-mode .track {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
import Track, { TrackEvents } from "@/Track";
|
|
||||||
import TrackUnitView from "@/ui/TrackUnit/TrackUnitView";
|
|
||||||
import "./Track.css";
|
|
||||||
import { Capsule, h, ISubscriber, ISubscription, Rung, RungOptions } from "@djledda/ladder";
|
|
||||||
import AppState from "@/AppState";
|
|
||||||
|
|
||||||
export type TrackUINodeOptions = RungOptions & {
|
|
||||||
state: AppState,
|
|
||||||
track: Track,
|
|
||||||
};
|
|
||||||
|
|
||||||
const EventTypeSubscriptions = [
|
|
||||||
TrackEvents.NewName,
|
|
||||||
TrackEvents.NewTimeSig,
|
|
||||||
TrackEvents.NewBarCount,
|
|
||||||
TrackEvents.DisplayTypeChanged,
|
|
||||||
TrackEvents.LoopLengthChanged,
|
|
||||||
];
|
|
||||||
|
|
||||||
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
|
|
||||||
|
|
||||||
export default class TrackView extends Rung implements ISubscriber<EventTypeSubscriptions> {
|
|
||||||
private track!: Track;
|
|
||||||
private title: HTMLHeadingElement;
|
|
||||||
private trackUnitViews: TrackUnitView[] = [];
|
|
||||||
private trackUnitViewBlock: HTMLElement | null = null;
|
|
||||||
private sub: ISubscription | null = null;
|
|
||||||
private state: AppState;
|
|
||||||
static deselectingUnits = false;
|
|
||||||
static selectingUnits = false;
|
|
||||||
|
|
||||||
constructor(options: TrackUINodeOptions) {
|
|
||||||
super(options);
|
|
||||||
this.state = options.state;
|
|
||||||
this.title = <h3></h3> as HTMLHeadingElement;
|
|
||||||
this.setTrack(options.track);
|
|
||||||
}
|
|
||||||
|
|
||||||
getTitleNode(): HTMLHeadingElement {
|
|
||||||
return this.title;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTrack(track: Track | null): void {
|
|
||||||
if (track) {
|
|
||||||
this.track = track;
|
|
||||||
this.sub?.unbind();
|
|
||||||
this.title.innerText = this.track.getName();
|
|
||||||
this.sub = this.track.addSubscriber(this, EventTypeSubscriptions);
|
|
||||||
this.redraw();
|
|
||||||
} else {
|
|
||||||
this.sub?.unbind();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
notify(publisher: unknown, event: EventTypeSubscriptions): void {
|
|
||||||
switch (event) {
|
|
||||||
case TrackEvents.NewName:
|
|
||||||
this.title.innerText = this.track.getName();
|
|
||||||
break;
|
|
||||||
case TrackEvents.NewTimeSig:
|
|
||||||
case TrackEvents.NewBarCount:
|
|
||||||
case TrackEvents.DisplayTypeChanged:
|
|
||||||
case TrackEvents.LoopLengthChanged:
|
|
||||||
this.setupTrackUnits();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private rebuildTrackUnitViews() {
|
|
||||||
const trackUnitCount = this.track.getBarCount() * this.track.getTimeSigUp();
|
|
||||||
for (let i = 0; i < trackUnitCount; i++) {
|
|
||||||
const trackUnit = this.track.getUnitByIndex(i);
|
|
||||||
if (trackUnit) {
|
|
||||||
let view: TrackUnitView;
|
|
||||||
if (this.trackUnitViews[i]) {
|
|
||||||
view = this.trackUnitViews[i];
|
|
||||||
view.setUnit(trackUnit);
|
|
||||||
} else {
|
|
||||||
view = new TrackUnitView({ trackUnit });
|
|
||||||
this.trackUnitViews.push(view);
|
|
||||||
view.onHover(() => this.applyCurrentToolToTrackUnit(view));
|
|
||||||
view.onMouseDown((event: MouseEvent) => this.onTrackUnitClick(event.button, view));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const deadViews = this.trackUnitViews.splice(trackUnitCount, this.trackUnitViews.length - trackUnitCount);
|
|
||||||
deadViews.forEach(trackUnitView => trackUnitView.setUnit(null));
|
|
||||||
}
|
|
||||||
|
|
||||||
private onTrackUnitClick(button: number, view: TrackUnitView) {
|
|
||||||
if (button === 0) {
|
|
||||||
TrackView.selectingUnits = true;
|
|
||||||
} else if (button === 2) {
|
|
||||||
TrackView.deselectingUnits = true;
|
|
||||||
}
|
|
||||||
this.applyCurrentToolToTrackUnit(view);
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyCurrentToolToTrackUnit(trackUnitView: TrackUnitView) {
|
|
||||||
if (this.state.selectedTool === "sticking") {
|
|
||||||
if (TrackView.selectingUnits) {
|
|
||||||
trackUnitView.setStickingType(this.state.activeStickingType);
|
|
||||||
} else if (TrackView.deselectingUnits) {
|
|
||||||
trackUnitView.setStickingType("none");
|
|
||||||
}
|
|
||||||
} else if (this.state.selectedTool === "track-unit-type") {
|
|
||||||
if (TrackView.selectingUnits) {
|
|
||||||
trackUnitView.turnOn();
|
|
||||||
trackUnitView.setType(this.state.activeTrackUnitType);
|
|
||||||
} else if (TrackView.deselectingUnits) {
|
|
||||||
trackUnitView.turnOff();
|
|
||||||
}
|
|
||||||
} else if (this.state.selectedTool === "eraser") {
|
|
||||||
if (TrackView.selectingUnits || TrackView.deselectingUnits) {
|
|
||||||
trackUnitView.turnOff();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildTrackUnitViewBlock(): void {
|
|
||||||
const trackUnitNodes: HTMLElement[] = [];
|
|
||||||
for (let i = 0; i < this.trackUnitViews.length; i++) {
|
|
||||||
trackUnitNodes.push(this.trackUnitViews[i].render());
|
|
||||||
}
|
|
||||||
if (this.trackUnitViewBlock) {
|
|
||||||
this.trackUnitViewBlock.replaceChildren(...trackUnitNodes);
|
|
||||||
} else {
|
|
||||||
this.trackUnitViewBlock = <div className={"track-unit-block"}>{...trackUnitNodes}</div> as HTMLDivElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private respaceTrackUnits(): void {
|
|
||||||
if (!this.trackUnitViewBlock) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.trackUnitViewBlock.querySelectorAll(".unit-spacer").forEach(spacer => spacer.remove());
|
|
||||||
const barLength = this.track.getTimeSigUp();
|
|
||||||
const barCount = this.track.getBarCount();
|
|
||||||
let bars = 0;
|
|
||||||
let i = -1;
|
|
||||||
let spacersInserted = false;
|
|
||||||
while (!spacersInserted) {
|
|
||||||
i += barLength;
|
|
||||||
const newSpacer = <div className={"track-spacer"} /> as HTMLDivElement;
|
|
||||||
const leftNeighbour = this.trackUnitViewBlock.children.item(i);
|
|
||||||
if (leftNeighbour) {
|
|
||||||
leftNeighbour.insertAdjacentElement("afterend", newSpacer);
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
bars++;
|
|
||||||
if (bars === barCount) {
|
|
||||||
spacersInserted = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupTrackUnits(): void {
|
|
||||||
this.rebuildTrackUnitViews();
|
|
||||||
this.buildTrackUnitViewBlock();
|
|
||||||
this.respaceTrackUnits();
|
|
||||||
}
|
|
||||||
|
|
||||||
build(): HTMLElement {
|
|
||||||
this.setupTrackUnits();
|
|
||||||
if (!this.trackUnitViewBlock) {
|
|
||||||
throw new Error("Beat unit block setup failed!");
|
|
||||||
}
|
|
||||||
return <div className={"track"}>
|
|
||||||
<div className={"track-main"}>
|
|
||||||
{this.trackUnitViewBlock}
|
|
||||||
</div>
|
|
||||||
</div> as HTMLElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("mouseup", () => {
|
|
||||||
TrackView.selectingUnits = false;
|
|
||||||
TrackView.deselectingUnits = false;
|
|
||||||
});
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
.track-settings {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-settings-title-container {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-settings-title-container input {
|
|
||||||
min-width: 100%;
|
|
||||||
height: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-settings-title-container > div {
|
|
||||||
width: 100%;
|
|
||||||
font-weight: bold;
|
|
||||||
padding: 0.5em;
|
|
||||||
transition: background-color 200ms;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-settings-title-container > div:hover {
|
|
||||||
background-color: var(--color-ui-neutral-dark-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-settings-lower {
|
|
||||||
height: 3.5em;
|
|
||||||
display: flex;
|
|
||||||
text-align: center;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
.track-settings-lower > * {
|
|
||||||
margin-right: 0.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-settings-lower:last-child {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-settings .loop-settings {
|
|
||||||
text-align: left;
|
|
||||||
flex: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-settings .loop-settings-option.hide {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-settings .loop-settings-option {
|
|
||||||
flex: auto;
|
|
||||||
padding-right: 1em;
|
|
||||||
}
|
|
||||||
92
src/ui/TrackSettings/TrackSettings.vue
Normal file
92
src/ui/TrackSettings/TrackSettings.vue
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<div class="track-settings" v-if="track && beat">
|
||||||
|
<div class="track-settings-title-container">
|
||||||
|
<editable-text-field v-model="track!.name.value" />
|
||||||
|
</div>
|
||||||
|
<div class="track-settings-lower">
|
||||||
|
<action-button icon-name="snowflake" type="secondary" alt="Bake Loops" :disabled="!track!.looping.value" @click="track!.bakeLoops()" />
|
||||||
|
<action-button icon-name="trash" type="secondary" alt="Delete Track" @click="beat!.removeTrack(trackIndex)" />
|
||||||
|
<div class="loop-settings">
|
||||||
|
<bool-box label="Loop:" v-model="track!.looping.value" />
|
||||||
|
</div>
|
||||||
|
<div class="loop-settings-option" :class="{ hide: !track!.looping.value }">
|
||||||
|
<number-input v-model="track!.loopLength.value" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import NumberInput from "@/ui/Widgets/NumberInput/NumberInput.vue";
|
||||||
|
import BoolBox from "@/ui/Widgets/BoolBox/BoolBox.vue";
|
||||||
|
import ActionButton from "@/ui/Widgets/ActionButton/ActionButton.vue";
|
||||||
|
import EditableTextField from "@/ui/Widgets/EditableTextField/EditableTextField.vue";
|
||||||
|
import { useBeatStore } from "@/BeatStore";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
beatIndex: number,
|
||||||
|
trackIndex: number,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { beats } = useBeatStore();
|
||||||
|
const beat = computed(() => beats.value[props.beatIndex] ?? null);
|
||||||
|
const track = computed(() => beat.value?.tracks.value[props.trackIndex] ?? null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.track-settings {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-settings-title-container {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-settings-title-container input {
|
||||||
|
min-width: 100%;
|
||||||
|
height: 2em;
|
||||||
|
}
|
||||||
|
p
|
||||||
|
.track-settings-title-container > div {
|
||||||
|
width: 100%;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 0.5em;
|
||||||
|
transition: background-color 200ms;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-settings-title-container > div:hover {
|
||||||
|
background-color: var(--color-ui-neutral-dark-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-settings-lower {
|
||||||
|
height: 3.5em;
|
||||||
|
display: flex;
|
||||||
|
text-align: center;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
.track-settings-lower > * {
|
||||||
|
margin-right: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-settings-lower:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-settings .loop-settings {
|
||||||
|
text-align: left;
|
||||||
|
flex: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-settings .loop-settings-option.hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-settings .loop-settings-option {
|
||||||
|
flex: auto;
|
||||||
|
padding-right: 1em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import "./TrackSettings.css";
|
|
||||||
import Track, { TrackEvents } from "@/Track";
|
|
||||||
import NumberInputView from "@/ui/Widgets/NumberInput/NumberInputView";
|
|
||||||
import BoolBoxView from "@/ui/Widgets/BoolBox/BoolBoxView";
|
|
||||||
import ActionButtonView from "@/ui/Widgets/ActionButton/ActionButtonView";
|
|
||||||
import EditableTextFieldView from "@/ui/Widgets/EditableTextFIeld/EditableTextFieldView";
|
|
||||||
import { h, ISubscriber, ISubscription, Rung, RungOptions } from "@djledda/ladder";
|
|
||||||
|
|
||||||
export type BeatSettingsViewUINodeOptions = RungOptions & {
|
|
||||||
track: Track,
|
|
||||||
};
|
|
||||||
|
|
||||||
const EventTypeSubscriptions = [
|
|
||||||
TrackEvents.NewName,
|
|
||||||
TrackEvents.LoopLengthChanged,
|
|
||||||
TrackEvents.DisplayTypeChanged,
|
|
||||||
];
|
|
||||||
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
|
|
||||||
|
|
||||||
export default class TrackSettingsView extends Rung implements ISubscriber<EventTypeSubscriptions> {
|
|
||||||
private track: Track;
|
|
||||||
private loopLengthInput!: NumberInputView;
|
|
||||||
private bakeButton!: ActionButtonView;
|
|
||||||
private loopCheckbox!: BoolBoxView;
|
|
||||||
private loopLengthSection!: HTMLDivElement;
|
|
||||||
private sub!: ISubscription;
|
|
||||||
private title!: EditableTextFieldView;
|
|
||||||
private editingTitle: boolean;
|
|
||||||
|
|
||||||
constructor(options: BeatSettingsViewUINodeOptions) {
|
|
||||||
super(options);
|
|
||||||
this.editingTitle = false;
|
|
||||||
this.track = options.track;
|
|
||||||
this.setupBindings();
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupBindings() {
|
|
||||||
this.sub = this.track.addSubscriber(this, EventTypeSubscriptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
setBeat(track: Track): void {
|
|
||||||
this.sub.unbind();
|
|
||||||
this.track = track;
|
|
||||||
this.setupBindings();
|
|
||||||
EventTypeSubscriptions.forEach(eventType => this.notify(null, eventType));
|
|
||||||
}
|
|
||||||
|
|
||||||
notify(publisher: unknown, event: EventTypeSubscriptions): void {
|
|
||||||
switch(event) {
|
|
||||||
case TrackEvents.NewName:
|
|
||||||
this.title.setText(this.track.getName());
|
|
||||||
break;
|
|
||||||
case TrackEvents.LoopLengthChanged:
|
|
||||||
this.loopLengthInput.setValue(this.track.getLoopLength());
|
|
||||||
break;
|
|
||||||
case TrackEvents.DisplayTypeChanged:
|
|
||||||
this.loopCheckbox.setValue(this.track.isLooping());
|
|
||||||
this.bakeButton.setDisabled(!this.track.isLooping());
|
|
||||||
if (this.track.isLooping()) {
|
|
||||||
this.loopLengthSection.classList.remove("hide");
|
|
||||||
} else {
|
|
||||||
this.loopLengthSection.classList.add("hide");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
build(): Node {
|
|
||||||
this.title = new EditableTextFieldView({
|
|
||||||
initialText: this.track.getName(),
|
|
||||||
setter: (newText: string) => this.track.setName(newText),
|
|
||||||
});
|
|
||||||
this.bakeButton = new ActionButtonView({
|
|
||||||
icon: "snowflake",
|
|
||||||
type: "secondary",
|
|
||||||
alt: "Bake Loops",
|
|
||||||
disabled: !this.track.isLooping(),
|
|
||||||
onClick: () => this.track.bakeLoops(),
|
|
||||||
});
|
|
||||||
this.loopLengthInput = new NumberInputView({
|
|
||||||
initialValue: this.track.getLoopLength(),
|
|
||||||
onDecrement: () => this.track.setLoopLength(this.track.getLoopLength() - 1),
|
|
||||||
onIncrement: () => this.track.setLoopLength(this.track.getLoopLength() + 1),
|
|
||||||
onNewInput: (input: number) => this.track.setLoopLength(input),
|
|
||||||
});
|
|
||||||
this.loopCheckbox = new BoolBoxView({
|
|
||||||
label: "Loop:",
|
|
||||||
value: this.track.isLooping(),
|
|
||||||
onInput: (isChecked: boolean) => this.track.setLooping(isChecked),
|
|
||||||
});
|
|
||||||
this.loopLengthSection = <div className={"loop-settings-option"}>{this.loopLengthInput}</div> as HTMLDivElement;
|
|
||||||
if (this.track.isLooping()) {
|
|
||||||
this.loopLengthSection.classList.remove("hide");
|
|
||||||
} else {
|
|
||||||
this.loopLengthSection.classList.add("hide");
|
|
||||||
}
|
|
||||||
return <div className={"track-settings"}>
|
|
||||||
<div className={"track-settings-title-container"}>
|
|
||||||
{this.title}
|
|
||||||
</div>
|
|
||||||
<div className={"track-settings-lower"}>
|
|
||||||
{this.bakeButton}
|
|
||||||
{new ActionButtonView({
|
|
||||||
icon: "trash",
|
|
||||||
type: "secondary",
|
|
||||||
alt: "Delete Track",
|
|
||||||
onClick: () => this.track.delete(),
|
|
||||||
})}
|
|
||||||
<div className={"loop-settings"}>
|
|
||||||
{this.loopCheckbox}
|
|
||||||
</div>
|
|
||||||
{this.loopLengthSection}
|
|
||||||
</div>
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
.track-unit {
|
|
||||||
width: 2em;
|
|
||||||
height: 2em;
|
|
||||||
margin-right: 4px;
|
|
||||||
background-color: #464646;
|
|
||||||
border-color: #464646;
|
|
||||||
border-width: 0.1em 0.1em 0.1em 0.1em;
|
|
||||||
border-style: solid;
|
|
||||||
display: inline-block;
|
|
||||||
transition: background-color 100ms, border-color 100ms;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-unit.highlightable:hover {
|
|
||||||
border-color: #5f5f5f;
|
|
||||||
background-color: #5f5f5f;
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vertical-mode .track-unit {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-unit.on {
|
|
||||||
border-color: var(--color-ui-accent);
|
|
||||||
background-color: var(--color-ui-accent);
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-unit.on.highlightable:hover {
|
|
||||||
border-color: var(--color-ui-accent-hover);
|
|
||||||
background-color: var(--color-ui-accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-unit.on.Accent {
|
|
||||||
border-color: var(--color-ui-neutral-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-unit.on.Ghost {
|
|
||||||
opacity: 60%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-unit .icon-view {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.track-unit.on.icon-visible .icon-view {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
140
src/ui/TrackUnit/TrackUnit.vue
Normal file
140
src/ui/TrackUnit/TrackUnit.vue
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="classes"
|
||||||
|
@mousedown="handleMouseDown"
|
||||||
|
@mouseup="handleMouseUp"
|
||||||
|
@mouseout="handleMouseOut"
|
||||||
|
@touchstart="handleTouchStart"
|
||||||
|
@touchend="handleTouchEnd"
|
||||||
|
@contextmenu="() => false">
|
||||||
|
<icon :icon-name="iconName" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { TrackUnitStickingTypeList, TrackUnitTypeList } from "@/Track";
|
||||||
|
import Icon from "@/ui/Widgets/Icon/Icon.vue";
|
||||||
|
import { StickingTypeIconMap, getClasses } from "./trackUnit";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useAppStateStore } from "@/AppState";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
stickingType: number,
|
||||||
|
type: number,
|
||||||
|
on: boolean,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'rotateType'): true,
|
||||||
|
(e: 'mousedown'): true,
|
||||||
|
(e: 'mouseup'): true,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const {
|
||||||
|
selectingUnits,
|
||||||
|
deselectingUnits,
|
||||||
|
} = useAppStateStore();
|
||||||
|
|
||||||
|
let rotationTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let blockNextMouseUp = false;
|
||||||
|
|
||||||
|
const classes = computed(() => getClasses({
|
||||||
|
on: props.on,
|
||||||
|
stickingType: TrackUnitStickingTypeList[props.stickingType] ?? 'none',
|
||||||
|
type: TrackUnitTypeList[props.type] ?? 'Normal',
|
||||||
|
highlightable: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const iconName = computed(() => StickingTypeIconMap[TrackUnitStickingTypeList[props.stickingType] ?? 'none']);
|
||||||
|
|
||||||
|
function handleMouseDown(ev: MouseEvent): void {
|
||||||
|
if (ev.button === 0) {
|
||||||
|
selectingUnits.value = true;
|
||||||
|
emit('mousedown');
|
||||||
|
} else if (ev.button === 2) {
|
||||||
|
deselectingUnits.value = true;
|
||||||
|
emit('mousedown');
|
||||||
|
} else if (ev.button === 1) {
|
||||||
|
emit('rotateType');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseOut(ev: MouseEvent): void {
|
||||||
|
if (rotationTimeout) {
|
||||||
|
clearTimeout(rotationTimeout);
|
||||||
|
rotationTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseUp(ev: MouseEvent): void {
|
||||||
|
if (!blockNextMouseUp) {
|
||||||
|
emit('mouseup');
|
||||||
|
}
|
||||||
|
blockNextMouseUp = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchStart(ev: TouchEvent): void {
|
||||||
|
rotationTimeout = rotationTimeout || setTimeout(() => {
|
||||||
|
emit('rotateType');
|
||||||
|
rotationTimeout = null;
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd(ev: TouchEvent): void {
|
||||||
|
if (rotationTimeout) {
|
||||||
|
clearTimeout(rotationTimeout);
|
||||||
|
rotationTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.track-unit {
|
||||||
|
width: 2em;
|
||||||
|
height: 2em;
|
||||||
|
margin-right: 4px;
|
||||||
|
background-color: #464646;
|
||||||
|
border-color: #464646;
|
||||||
|
border-width: 0.1em 0.1em 0.1em 0.1em;
|
||||||
|
border-style: solid;
|
||||||
|
display: inline-block;
|
||||||
|
transition: background-color 100ms, border-color 100ms;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-unit.highlightable:hover {
|
||||||
|
border-color: #5f5f5f;
|
||||||
|
background-color: #5f5f5f;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-mode .track-unit {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-unit.on {
|
||||||
|
border-color: var(--color-ui-accent);
|
||||||
|
background-color: var(--color-ui-accent);
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-unit.on.highlightable:hover {
|
||||||
|
border-color: var(--color-ui-accent-hover);
|
||||||
|
background-color: var(--color-ui-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-unit.on.Accent {
|
||||||
|
border-color: var(--color-ui-neutral-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-unit.on.Ghost {
|
||||||
|
opacity: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-unit .icon-view {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.track-unit.on.icon-visible .icon-view {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
import TrackUnit, { TrackUnitEvent, TrackUnitStickingType, TrackUnitType } from "@/TrackUnit";
|
|
||||||
import "./TrackUnit.css";
|
|
||||||
import { Capsule, h, IPublisher, ISubscriber, ISubscription, Publisher, Rung, RungOptions } from "@djledda/ladder";
|
|
||||||
import IconView, { IconName } from "@/ui/Widgets/Icon/IconView";
|
|
||||||
|
|
||||||
export type TrackUnitUINodeOptions = RungOptions & {
|
|
||||||
trackUnit: TrackUnit,
|
|
||||||
};
|
|
||||||
|
|
||||||
const EventTypeSubscriptions = [
|
|
||||||
TrackUnitEvent.On,
|
|
||||||
TrackUnitEvent.Off,
|
|
||||||
TrackUnitEvent.TypeChange,
|
|
||||||
];
|
|
||||||
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
|
|
||||||
|
|
||||||
export const StickingTypeIconMap = {
|
|
||||||
none: null,
|
|
||||||
lf: 'lf',
|
|
||||||
lh: 'lh',
|
|
||||||
rf: 'rf',
|
|
||||||
rh: 'rh',
|
|
||||||
} as const satisfies Readonly<Record<TrackUnitStickingType, IconName | null>>;
|
|
||||||
|
|
||||||
const TypeClasses = [ "Ghost", "Accent" ] as const;
|
|
||||||
export const TrackUnitTypeClassMap = {
|
|
||||||
"Normal": [],
|
|
||||||
"GhostNote": ["Ghost"],
|
|
||||||
"Accent": ["Accent"],
|
|
||||||
"GhostNoteAccent": ["Ghost", "Accent"],
|
|
||||||
} as const satisfies Readonly<Record<TrackUnitType, Readonly<string[]>>>;
|
|
||||||
|
|
||||||
export default class TrackUnitView extends Rung<HTMLDivElement> implements ISubscriber<EventTypeSubscriptions> {
|
|
||||||
private trackUnit: TrackUnit;
|
|
||||||
private subscription: ISubscription | null = null;
|
|
||||||
private publisher: IPublisher<TrackUnitEvent> = new Publisher<TrackUnitEvent, TrackUnitView>(this);
|
|
||||||
private rotationTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
private mouseDownListeners: ((ev: MouseEvent) => void)[] = [];
|
|
||||||
private hoverListeners: ((ev: MouseEvent) => void)[] = [];
|
|
||||||
private blockNextMouseUp = false;
|
|
||||||
private icon: IconView = new IconView({ iconName: 'list' });
|
|
||||||
|
|
||||||
constructor(options: TrackUnitUINodeOptions) {
|
|
||||||
super(options);
|
|
||||||
this.trackUnit = options.trackUnit;
|
|
||||||
this.setUnit(this.trackUnit);
|
|
||||||
}
|
|
||||||
|
|
||||||
setUnit(trackUnit: TrackUnit | null): void {
|
|
||||||
if (trackUnit) {
|
|
||||||
this.trackUnit = trackUnit;
|
|
||||||
this.setupBindings();
|
|
||||||
this.notify(this.publisher, trackUnit.isOn() ? TrackUnitEvent.On : TrackUnitEvent.Off);
|
|
||||||
this.notify(this.publisher, TrackUnitEvent.TypeChange);
|
|
||||||
} else {
|
|
||||||
this.subscription?.unbind();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupBindings() {
|
|
||||||
this.subscription?.unbind();
|
|
||||||
this.subscription = this.trackUnit.addSubscriber(this, EventTypeSubscriptions);
|
|
||||||
this.hoverListeners.forEach(listener => this.render().removeEventListener("mouseover", listener));
|
|
||||||
this.mouseDownListeners.forEach(listener => this.render().removeEventListener("mousedown", listener));
|
|
||||||
this.redraw();
|
|
||||||
this.hoverListeners.forEach(listener => this.render().addEventListener("mouseover", listener));
|
|
||||||
this.mouseDownListeners.forEach(listener => this.render().addEventListener("mousedown", listener));
|
|
||||||
this.render().addEventListener("mousedown", (ev) => this.handleMouseDown(ev));
|
|
||||||
this.render().addEventListener("mouseout", (ev) => this.handleMouseOut(ev));
|
|
||||||
this.render().addEventListener("mouseup", (ev) => this.handleMouseUp(ev));
|
|
||||||
this.render().addEventListener("touchstart", (ev) => this.handleTouchStart(ev));
|
|
||||||
this.render().addEventListener("touchend", (ev) => this.handleTouchEnd(ev));
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleMouseDown(ev: MouseEvent): void {
|
|
||||||
if (ev.button === 1) {
|
|
||||||
this.trackUnit.rotateType();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleMouseOut(ev: MouseEvent): void {
|
|
||||||
if (this.rotationTimeout) {
|
|
||||||
clearTimeout(this.rotationTimeout);
|
|
||||||
this.rotationTimeout = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleMouseUp(ev: MouseEvent): void {
|
|
||||||
if (!this.blockNextMouseUp) {
|
|
||||||
this.mouseDownListeners.forEach(listener => listener(ev));
|
|
||||||
}
|
|
||||||
this.blockNextMouseUp = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleTouchStart(ev: TouchEvent): void {
|
|
||||||
this.rotationTimeout = this.rotationTimeout || setTimeout(() => {
|
|
||||||
this.trackUnit.rotateType();
|
|
||||||
this.rotationTimeout = null;
|
|
||||||
}, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleTouchEnd(ev: TouchEvent): void {
|
|
||||||
if (this.rotationTimeout) {
|
|
||||||
clearTimeout(this.rotationTimeout);
|
|
||||||
this.rotationTimeout = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle(): void {
|
|
||||||
this.trackUnit.toggle();
|
|
||||||
}
|
|
||||||
|
|
||||||
setStickingType(type: TrackUnitStickingType): void {
|
|
||||||
if (this.trackUnit.isOn()) {
|
|
||||||
this.trackUnit.setStickingType(type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setType(type: TrackUnitType): void {
|
|
||||||
this.trackUnit.setType(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
turnOn(): void {
|
|
||||||
this.trackUnit.setOn(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
turnOff(): void {
|
|
||||||
this.trackUnit.setStickingType("none");
|
|
||||||
this.trackUnit.setOn(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
notify(publisher: unknown, event: EventTypeSubscriptions): void {
|
|
||||||
switch (event) {
|
|
||||||
case TrackUnitEvent.On:
|
|
||||||
this.render().classList.add("on");
|
|
||||||
break;
|
|
||||||
case TrackUnitEvent.Off:
|
|
||||||
this.render().classList.remove("on");
|
|
||||||
break;
|
|
||||||
case TrackUnitEvent.TypeChange:
|
|
||||||
this.syncTrackUnitType();
|
|
||||||
this.syncStickingType(this.trackUnit.getStickingType());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private syncStickingType(type: TrackUnitStickingType) {
|
|
||||||
if (StickingTypeIconMap[this.trackUnit.getStickingType()]) {
|
|
||||||
this.render().classList.add("icon-visible");
|
|
||||||
} else {
|
|
||||||
this.render().classList.remove("icon-visible");
|
|
||||||
}
|
|
||||||
const icon = StickingTypeIconMap[this.trackUnit.getStickingType()];
|
|
||||||
if (icon) {
|
|
||||||
this.icon.setIcon(icon);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private syncTrackUnitType() {
|
|
||||||
for (const className of TypeClasses) {
|
|
||||||
this.render().classList.remove(className);
|
|
||||||
}
|
|
||||||
for (const className of TrackUnitTypeClassMap[this.trackUnit.getType()]) {
|
|
||||||
this.render().classList.add(className);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static getClasses(options: { on: boolean, stickingType: TrackUnitStickingType, type: TrackUnitType, highlightable?: boolean }) {
|
|
||||||
const classes = ["track-unit"];
|
|
||||||
if (options.on) {
|
|
||||||
classes.push("on");
|
|
||||||
}
|
|
||||||
if (StickingTypeIconMap[options.stickingType]) {
|
|
||||||
classes.push("icon-visible");
|
|
||||||
}
|
|
||||||
if (options.type) {
|
|
||||||
classes.push(...TrackUnitTypeClassMap[options.type]);
|
|
||||||
}
|
|
||||||
if (options.highlightable) {
|
|
||||||
classes.push("highlightable");
|
|
||||||
}
|
|
||||||
return classes;
|
|
||||||
}
|
|
||||||
|
|
||||||
build() {
|
|
||||||
const classes = TrackUnitView.getClasses({
|
|
||||||
on: this.trackUnit.isOn(),
|
|
||||||
stickingType: this.trackUnit.getStickingType(),
|
|
||||||
type: this.trackUnit.getType(),
|
|
||||||
highlightable: true,
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<div classes={classes} oncontextmenu={() => false}>
|
|
||||||
{this.icon}
|
|
||||||
</div>
|
|
||||||
) as HTMLDivElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
onHover(cb: () => void): void {
|
|
||||||
this.hoverListeners.push(cb);
|
|
||||||
this.render().addEventListener("mouseover", cb);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMouseDown(cb: (ev: MouseEvent) => void): void {
|
|
||||||
this.mouseDownListeners.push(cb);
|
|
||||||
this.render().addEventListener("mousedown", cb);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
36
src/ui/TrackUnit/trackUnit.ts
Normal file
36
src/ui/TrackUnit/trackUnit.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { TrackUnitStickingType, TrackUnitType } from "@/Track";
|
||||||
|
import type { IconName } from "@/ui/Widgets/Icon/icons";
|
||||||
|
|
||||||
|
export const TypeClasses = [ "Ghost", "Accent" ] as const;
|
||||||
|
|
||||||
|
export const StickingTypeIconMap = {
|
||||||
|
none: null,
|
||||||
|
lf: 'lf',
|
||||||
|
lh: 'lh',
|
||||||
|
rf: 'rf',
|
||||||
|
rh: 'rh',
|
||||||
|
} as const satisfies Readonly<Record<TrackUnitStickingType, IconName | null>>;
|
||||||
|
|
||||||
|
export const TrackUnitTypeClassMap = {
|
||||||
|
"Normal": [],
|
||||||
|
"GhostNote": ["Ghost"],
|
||||||
|
"Accent": ["Accent"],
|
||||||
|
"GhostNoteAccent": ["Ghost", "Accent"],
|
||||||
|
} as const satisfies Readonly<Record<TrackUnitType, Readonly<string[]>>>;
|
||||||
|
|
||||||
|
export function getClasses(options: { on: boolean, stickingType: TrackUnitStickingType, type: TrackUnitType, highlightable?: boolean }) {
|
||||||
|
const classes = ["track-unit"];
|
||||||
|
if (options.on) {
|
||||||
|
classes.push("on");
|
||||||
|
}
|
||||||
|
if (StickingTypeIconMap[options.stickingType]) {
|
||||||
|
classes.push("icon-visible");
|
||||||
|
}
|
||||||
|
if (options.type) {
|
||||||
|
classes.push(...TrackUnitTypeClassMap[options.type]);
|
||||||
|
}
|
||||||
|
if (options.highlightable) {
|
||||||
|
classes.push("highlightable");
|
||||||
|
}
|
||||||
|
return classes;
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
.action-button {
|
|
||||||
border-radius: 0.5em;
|
|
||||||
margin: 0.5em;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-button.disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-button.action-button-primary {
|
|
||||||
background-color: var(--color-ui-accent);
|
|
||||||
color: var(--color-p-light);
|
|
||||||
}
|
|
||||||
.action-button.action-button-primary:hover {
|
|
||||||
background-color: var(--color-ui-accent-hover);
|
|
||||||
color: var(--color-p-light-hover);
|
|
||||||
}
|
|
||||||
.action-button.action-button-primary:active {
|
|
||||||
background-color: var(--color-ui-accent-active);
|
|
||||||
color: var(--color-p-light-active);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-button.action-button-secondary {
|
|
||||||
background-color: var(--color-ui-neutral-dark);
|
|
||||||
color: var(--color-p-light);
|
|
||||||
}
|
|
||||||
.action-button.action-button-secondary:hover:not(.disabled) {
|
|
||||||
background-color: var(--color-ui-neutral-dark-hover);
|
|
||||||
color: var(--color-p-light-hover);
|
|
||||||
}
|
|
||||||
.action-button.action-button-secondary:active:not(.disabled) {
|
|
||||||
background-color: var(--color-ui-neutral-dark-active);
|
|
||||||
color: var(--color-p-light-active);
|
|
||||||
}
|
|
||||||
78
src/ui/Widgets/ActionButton/ActionButton.vue
Normal file
78
src/ui/Widgets/ActionButton/ActionButton.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="action-button"
|
||||||
|
:class="{
|
||||||
|
disabled,
|
||||||
|
[`action-button-${ type }`]: true,
|
||||||
|
}"
|
||||||
|
:alt="alt"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="emit('click')">
|
||||||
|
<icon v-if="iconName"
|
||||||
|
:icon-name="iconName"
|
||||||
|
color="var(--color-p-light)" />
|
||||||
|
<span v-else>{{ label ?? '-' }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { type IconName } from "@/ui/Widgets/Icon/icons";
|
||||||
|
import Icon from "@/ui/Widgets/Icon/Icon.vue";
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
type?: 'primary' | 'secondary',
|
||||||
|
alt?: string | null,
|
||||||
|
disabled?: boolean,
|
||||||
|
iconName?: IconName,
|
||||||
|
label?: string,
|
||||||
|
}>(), {
|
||||||
|
type: 'primary',
|
||||||
|
alt: null,
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'click'): true,
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.action-button {
|
||||||
|
border-radius: 0.5em;
|
||||||
|
margin: 0.5em;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.action-button-primary {
|
||||||
|
background-color: var(--color-ui-accent);
|
||||||
|
color: var(--color-p-light);
|
||||||
|
}
|
||||||
|
.action-button.action-button-primary:hover {
|
||||||
|
background-color: var(--color-ui-accent-hover);
|
||||||
|
color: var(--color-p-light-hover);
|
||||||
|
}
|
||||||
|
.action-button.action-button-primary:active {
|
||||||
|
background-color: var(--color-ui-accent-active);
|
||||||
|
color: var(--color-p-light-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.action-button-secondary {
|
||||||
|
background-color: var(--color-ui-neutral-dark);
|
||||||
|
color: var(--color-p-light);
|
||||||
|
}
|
||||||
|
.action-button.action-button-secondary:hover:not(.disabled) {
|
||||||
|
background-color: var(--color-ui-neutral-dark-hover);
|
||||||
|
color: var(--color-p-light-hover);
|
||||||
|
}
|
||||||
|
.action-button.action-button-secondary:active:not(.disabled) {
|
||||||
|
background-color: var(--color-ui-neutral-dark-active);
|
||||||
|
color: var(--color-p-light-active);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import "./ActionButton.css";
|
|
||||||
import IconView, { IconName } from "@/ui/Widgets/Icon/IconView";
|
|
||||||
import { h, Rung, RungOptions } from "@djledda/ladder";
|
|
||||||
|
|
||||||
export type ActionButtonUINodeOptions = RungOptions & {
|
|
||||||
type?: "primary" | "secondary",
|
|
||||||
onClick?: (event: MouseEvent) => void,
|
|
||||||
alt?: string,
|
|
||||||
disabled?: boolean,
|
|
||||||
} & ({
|
|
||||||
icon: IconName,
|
|
||||||
label?: never,
|
|
||||||
} | {
|
|
||||||
label: string,
|
|
||||||
icon?: never,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default class ActionButtonView extends Rung<HTMLButtonElement> {
|
|
||||||
private label: string | null = null;
|
|
||||||
private icon: IconName | null = null;
|
|
||||||
private buttonElement!: HTMLButtonElement;
|
|
||||||
private onClick: (event: MouseEvent) => void;
|
|
||||||
private type: "primary" | "secondary";
|
|
||||||
private alt: string | null;
|
|
||||||
private disabled: boolean;
|
|
||||||
|
|
||||||
constructor(options: ActionButtonUINodeOptions) {
|
|
||||||
super(options);
|
|
||||||
if (typeof options.icon !== "undefined") {
|
|
||||||
this.icon = options.icon;
|
|
||||||
} else if (typeof options.label !== "undefined") {
|
|
||||||
this.label = options.label;
|
|
||||||
}
|
|
||||||
this.disabled = options.disabled ?? false;
|
|
||||||
this.alt = options.alt ?? null;
|
|
||||||
this.type = options.type ?? "primary";
|
|
||||||
this.onClick = options.onClick ?? (() => { /* dummy */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
setDisabled(isDisabled: boolean): void {
|
|
||||||
this.disabled = isDisabled;
|
|
||||||
this.buttonElement.disabled = this.disabled;
|
|
||||||
if (isDisabled) {
|
|
||||||
this.buttonElement.classList.add("disabled");
|
|
||||||
} else {
|
|
||||||
this.buttonElement.classList.remove("disabled");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected build(): HTMLButtonElement {
|
|
||||||
this.buttonElement = (
|
|
||||||
<button
|
|
||||||
classes={["action-button", `action-button-${this.type}`]}
|
|
||||||
onclick={(event: MouseEvent) => this.disabled || this.onClick(event)}>
|
|
||||||
{
|
|
||||||
this.icon ? new IconView({
|
|
||||||
iconName: this.icon,
|
|
||||||
color: "var(--color-p-light)",
|
|
||||||
}) : <span>{this.label ?? ""}</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
) as HTMLButtonElement;
|
|
||||||
if (this.alt) {
|
|
||||||
this.buttonElement.title = this.alt;
|
|
||||||
}
|
|
||||||
if (this.disabled) {
|
|
||||||
this.buttonElement.classList.add("disabled");
|
|
||||||
}
|
|
||||||
return this.buttonElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
.bool-box {
|
|
||||||
height: 1.5em;
|
|
||||||
position: relative;
|
|
||||||
white-space: nowrap;
|
|
||||||
line-height: 1.5em;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bool-box-label {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: 0.5em;
|
|
||||||
margin-left: 0.5em;
|
|
||||||
top: -0.33em;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.bool-box-checkbox[type="checkbox"] {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
width: 3em;
|
|
||||||
height: 1.5em;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.bool-box-checkbox[type="checkbox"]::before {
|
|
||||||
top: 0.3em;
|
|
||||||
left: 0.3em;
|
|
||||||
width: 2.3em;
|
|
||||||
height: 0.9em;
|
|
||||||
border-radius: 1em;
|
|
||||||
background-color: var(--color-ui-accent-active);
|
|
||||||
display: inline-block;
|
|
||||||
content: "";
|
|
||||||
z-index: 0;
|
|
||||||
position: absolute;
|
|
||||||
transition: background-color 200ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.bool-box-checkbox[type="checkbox"]:checked::before {
|
|
||||||
background-color: var(--color-ui-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
input.bool-box-checkbox[type="checkbox"]::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
position: absolute;
|
|
||||||
width: 1.35em;
|
|
||||||
height: 1.35em;
|
|
||||||
border-radius: 100%;
|
|
||||||
background-color: var(--color-ui-neutral-dark);
|
|
||||||
display: block;
|
|
||||||
content: "";
|
|
||||||
top: 0.075em;
|
|
||||||
left: 0.025em;
|
|
||||||
z-index: 1;
|
|
||||||
transition: left 200ms, background-color 200ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.bool-box-checkbox[type="checkbox"]:checked::after {
|
|
||||||
left: 1.575em;
|
|
||||||
background-color: var(--color-ui-neutral-light);
|
|
||||||
}
|
|
||||||
97
src/ui/Widgets/BoolBox/BoolBox.vue
Normal file
97
src/ui/Widgets/BoolBox/BoolBox.vue
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bool-box">
|
||||||
|
<label
|
||||||
|
:class="{ visible: 'visible' }"
|
||||||
|
class="bool-box-label"
|
||||||
|
@click="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)">
|
||||||
|
{{ label ?? "" }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="bool-box-checkbox"
|
||||||
|
:checked="modelValue"
|
||||||
|
@click="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
label?: string,
|
||||||
|
modelValue?: boolean,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): true,
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.bool-box {
|
||||||
|
height: 1.5em;
|
||||||
|
position: relative;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.5em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bool-box-label {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
margin-left: 0.5em;
|
||||||
|
top: -0.33em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.bool-box-checkbox[type="checkbox"] {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 3em;
|
||||||
|
height: 1.5em;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.bool-box-checkbox[type="checkbox"]::before {
|
||||||
|
top: 0.3em;
|
||||||
|
left: 0.3em;
|
||||||
|
width: 2.3em;
|
||||||
|
height: 0.9em;
|
||||||
|
border-radius: 1em;
|
||||||
|
background-color: var(--color-ui-accent-active);
|
||||||
|
display: inline-block;
|
||||||
|
content: "";
|
||||||
|
z-index: 0;
|
||||||
|
position: absolute;
|
||||||
|
transition: background-color 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.bool-box-checkbox[type="checkbox"]:checked::before {
|
||||||
|
background-color: var(--color-ui-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.bool-box-checkbox[type="checkbox"]::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: absolute;
|
||||||
|
width: 1.35em;
|
||||||
|
height: 1.35em;
|
||||||
|
border-radius: 100%;
|
||||||
|
background-color: var(--color-ui-neutral-dark);
|
||||||
|
display: block;
|
||||||
|
content: "";
|
||||||
|
top: 0.075em;
|
||||||
|
left: 0.025em;
|
||||||
|
z-index: 1;
|
||||||
|
transition: left 200ms, background-color 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.bool-box-checkbox[type="checkbox"]:checked::after {
|
||||||
|
left: 1.575em;
|
||||||
|
background-color: var(--color-ui-neutral-light);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import "./BoolBox.css";
|
|
||||||
import { Capsule, h, Rung, RungOptions } from "@djledda/ladder";
|
|
||||||
|
|
||||||
export type BoolBoxUINodeOptions = RungOptions & {
|
|
||||||
label?: string,
|
|
||||||
value?: boolean,
|
|
||||||
onInput?: (isChecked: boolean) => void,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class BoolBoxView extends Rung {
|
|
||||||
private label: string | null;
|
|
||||||
private labelElement = Capsule.new<HTMLLabelElement | null>(null);
|
|
||||||
private checkboxElement = Capsule.new<HTMLInputElement | null>(null);
|
|
||||||
private onInput: (isChecked: boolean) => void;
|
|
||||||
|
|
||||||
constructor(options: BoolBoxUINodeOptions) {
|
|
||||||
super(options);
|
|
||||||
this.label = options.label ?? "";
|
|
||||||
this.onInput = options.onInput ?? (() => { /* dummy */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
setLabel(newLabel: string | null): void {
|
|
||||||
if (!this.labelElement.val) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (newLabel !== null) {
|
|
||||||
this.label = newLabel;
|
|
||||||
this.labelElement.val.innerText = newLabel;
|
|
||||||
this.labelElement.val.classList.add("visible");
|
|
||||||
} else {
|
|
||||||
this.label = newLabel;
|
|
||||||
this.labelElement.val.innerText = "";
|
|
||||||
this.labelElement.val.classList.remove("visible");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setValue(isChecked: boolean): void {
|
|
||||||
if (this.checkboxElement.val) {
|
|
||||||
this.checkboxElement.val.checked = isChecked;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
build(): HTMLDivElement {
|
|
||||||
return <div className={"bool-box"}>
|
|
||||||
<label
|
|
||||||
saveTo={this.labelElement}
|
|
||||||
classes={this.label ? ["bool-box-label", "visible"] : ["bool-box-label"]}
|
|
||||||
onclick={() => this.onInput(!this.checkboxElement.val?.checked)}>
|
|
||||||
{this.label ?? ""}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type={"checkbox"}
|
|
||||||
className={"bool-box-checkbox"}
|
|
||||||
saveTo={this.checkboxElement}
|
|
||||||
onclick={(event: Event) => this.onInput((event.target as HTMLInputElement).checked)}/>
|
|
||||||
</div> as HTMLDivElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import "./Dropdown.css";
|
|
||||||
import { Capsule, h, ICapsule, Rung, RungOptions } from "@djledda/ladder";
|
|
||||||
|
|
||||||
export type DropdownViewOption = {
|
|
||||||
label: string,
|
|
||||||
value: string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DropdownUINodeOptions = RungOptions & {
|
|
||||||
options: ICapsule<DropdownViewOption[]> | DropdownViewOption[],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class DropdownView extends Rung {
|
|
||||||
private options: ICapsule<DropdownViewOption[]>;
|
|
||||||
private select = Capsule.new<HTMLSelectElement | null>(null);
|
|
||||||
|
|
||||||
constructor(options: DropdownUINodeOptions) {
|
|
||||||
super(options);
|
|
||||||
this.options = Capsule.new(options.options);
|
|
||||||
this.options.watch((newVal) => this.updateOptionsFrom(newVal));
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateOptionsFrom(newOptions: DropdownViewOption[]): void {
|
|
||||||
const select = this.select.val;
|
|
||||||
if (!select) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const children = new Array(...select.children) as HTMLOptionElement[];
|
|
||||||
for (let i = 0; i < newOptions.length; i++) {
|
|
||||||
if (children[i]) {
|
|
||||||
children[i].label = newOptions[i].label;
|
|
||||||
children[i].value = newOptions[i].value;
|
|
||||||
} else {
|
|
||||||
children.push(<option label={newOptions[i].label} value={newOptions[i].value} /> as HTMLOptionElement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (children.length - newOptions.length > 0) {
|
|
||||||
children.splice(newOptions.length, children.length - newOptions.length).forEach(child => child.remove());
|
|
||||||
}
|
|
||||||
select.replaceChildren(...children);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected build(): HTMLSelectElement {
|
|
||||||
return <select saveTo={this.select}>
|
|
||||||
{this.options.val.map(opt => <option label={opt.label} />)}
|
|
||||||
</select> as HTMLSelectElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
71
src/ui/Widgets/EditableTextFIeld/EditableTextField.vue
Normal file
71
src/ui/Widgets/EditableTextFIeld/EditableTextField.vue
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<input
|
||||||
|
v-if="editing"
|
||||||
|
:value="modelValue"
|
||||||
|
ref="inputField"
|
||||||
|
class="editable-text-field-view"
|
||||||
|
type="text"
|
||||||
|
@input="onInput"
|
||||||
|
@blur="onBlur"
|
||||||
|
@keyup="onKeyUp"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="editable-text-field-view"
|
||||||
|
@click="editing = true">
|
||||||
|
{{ modelValue }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">import { ref, watch } from 'vue';
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string,
|
||||||
|
noEmpty?: boolean,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string): true,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let lastNonEmptyInput = "";
|
||||||
|
const editing = ref(false);
|
||||||
|
const inputField = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
watch(inputField, (newVal) => newVal && newVal.focus());
|
||||||
|
|
||||||
|
function onInput(event: Event) {
|
||||||
|
const input = (event.target as HTMLInputElement).value;
|
||||||
|
const inputToSet = props.noEmpty && input === "" ? lastNonEmptyInput : input;
|
||||||
|
emit('update:modelValue', inputToSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlur(event: FocusEvent) {
|
||||||
|
if ((event.target as HTMLInputElement).value === "") {
|
||||||
|
emit('update:modelValue', lastNonEmptyInput);
|
||||||
|
}
|
||||||
|
editing.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyUp(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
(event.target as HTMLInputElement).blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
input.editable-text-field-view {
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.editable-text-field-view {
|
||||||
|
width: 100%;
|
||||||
|
transition: background-color 200ms;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.editable-text-field-view:hover {
|
||||||
|
background-color: var(--color-ui-neutral-dark-hover);
|
||||||
|
}
|
||||||
|
</style>"
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
input.editable-text-field-view {
|
|
||||||
width: fit-content;
|
|
||||||
max-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.editable-text-field-view {
|
|
||||||
width: 100%;
|
|
||||||
transition: background-color 200ms;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.editable-text-field-view:hover {
|
|
||||||
background-color: var(--color-ui-neutral-dark-hover);
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import "./EditableTextFieldView.css";
|
|
||||||
import { Rung, h, RungOptions } from "@djledda/ladder";
|
|
||||||
|
|
||||||
export type EditableTextFieldViewOptions = RungOptions & {
|
|
||||||
initialText?: string,
|
|
||||||
setter?: (newString: string) => void,
|
|
||||||
noEmpty?: boolean,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class EditableTextFieldView extends Rung {
|
|
||||||
private text: string;
|
|
||||||
private titleInput!: HTMLInputElement;
|
|
||||||
private setter: (newString: string) => void;
|
|
||||||
private titleDisplay!: HTMLElement;
|
|
||||||
private noEmpty: boolean;
|
|
||||||
private lastNonEmptyInput = "";
|
|
||||||
|
|
||||||
constructor(options: EditableTextFieldViewOptions) {
|
|
||||||
super(options);
|
|
||||||
this.setter = options.setter ?? (() => {/* dummy */});
|
|
||||||
this.text = options.initialText ?? "";
|
|
||||||
this.noEmpty = options.noEmpty ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
setText(newText: string): void {
|
|
||||||
if (newText !== "" || !this.noEmpty) {
|
|
||||||
this.text = newText;
|
|
||||||
this.titleInput.value = this.text;
|
|
||||||
this.titleDisplay.innerText = this.text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
build(): Node {
|
|
||||||
this.titleInput = <input
|
|
||||||
value={this.text}
|
|
||||||
className={"editable-text-field-view"}
|
|
||||||
type={"text"}
|
|
||||||
oninput={(event: Event) => {
|
|
||||||
const input = (event.target as HTMLInputElement).value;
|
|
||||||
if (input === "") {
|
|
||||||
if (!this.noEmpty) {
|
|
||||||
this.setter(input);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.setter(input);
|
|
||||||
this.lastNonEmptyInput = input;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onblur={(event: FocusEvent) => {
|
|
||||||
if ((event.target as HTMLInputElement).value === "") {
|
|
||||||
this.setText(this.lastNonEmptyInput);
|
|
||||||
}
|
|
||||||
this.titleInput.replaceWith(this.titleDisplay);
|
|
||||||
}}
|
|
||||||
onkeyup={(event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
(event.target as HTMLInputElement).blur();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/> as HTMLInputElement;
|
|
||||||
|
|
||||||
this.titleDisplay = <div
|
|
||||||
innerText={this.text}
|
|
||||||
className={"editable-text-field-view"}
|
|
||||||
onclick={() => {
|
|
||||||
this.titleDisplay.replaceWith(this.titleInput);
|
|
||||||
this.titleInput.focus();
|
|
||||||
}} /> as HTMLDivElement;
|
|
||||||
|
|
||||||
return this.titleDisplay;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
.icon-view {
|
|
||||||
--icon-bg: black;
|
|
||||||
width: 2em;
|
|
||||||
height: 2em;
|
|
||||||
-webkit-mask-size: 2em;
|
|
||||||
mask-size: 2em;
|
|
||||||
display: inline-block;
|
|
||||||
background-color: var(--icon-bg);
|
|
||||||
}
|
|
||||||
33
src/ui/Widgets/Icon/Icon.vue
Normal file
33
src/ui/Widgets/Icon/Icon.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<div :style="cssText" class="icon-view" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { IconUrlMap, type IconName } from './icons';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
iconName: IconName | null,
|
||||||
|
color?: string,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const iconUrl = computed(() => props.iconName ? IconUrlMap[props.iconName] : null);
|
||||||
|
|
||||||
|
const cssText = computed(() => {
|
||||||
|
const colorString = props.color ? `--icon-bg:${ props.color }` : "";
|
||||||
|
return `-webkit-mask-image: url(${ iconUrl.value }); mask-image: url(${ iconUrl.value });${ colorString ?? '' }`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.icon-view {
|
||||||
|
--icon-bg: black;
|
||||||
|
width: 2em;
|
||||||
|
height: 2em;
|
||||||
|
-webkit-mask-size: 2em;
|
||||||
|
mask-size: 2em;
|
||||||
|
display: inline-block;
|
||||||
|
background-color: var(--icon-bg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { h, Rung, RungOptions } from "@djledda/ladder";
|
|
||||||
import "./Icon.css";
|
|
||||||
|
|
||||||
import List from "assets/svgs/list.svg";
|
|
||||||
import ArrowClockwise from "assets/svgs/arrow-clockwise.svg";
|
|
||||||
import Trash from "assets/svgs/trash.svg";
|
|
||||||
import Snowflake from "assets/svgs/snowflake.svg";
|
|
||||||
import LeftHand from "assets/svgs/LH.png";
|
|
||||||
import RightHand from "assets/svgs/RH.png";
|
|
||||||
import LeftFoot from "assets/svgs/LF.png";
|
|
||||||
import RightFoot from "assets/svgs/RF.png";
|
|
||||||
|
|
||||||
const IconUrlMap = {
|
|
||||||
arrowClockwise: ArrowClockwise,
|
|
||||||
list: List,
|
|
||||||
trash: Trash,
|
|
||||||
snowflake: Snowflake,
|
|
||||||
lh: LeftHand,
|
|
||||||
rh: RightHand,
|
|
||||||
lf: LeftFoot,
|
|
||||||
rf: RightFoot,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type IconName = keyof typeof IconUrlMap;
|
|
||||||
|
|
||||||
export type IconViewOptions = RungOptions & {
|
|
||||||
iconName: IconName,
|
|
||||||
color?: string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class IconView extends Rung<HTMLDivElement> {
|
|
||||||
private iconUrl: string;
|
|
||||||
private color: string | null;
|
|
||||||
|
|
||||||
constructor(options: IconViewOptions) {
|
|
||||||
super(options);
|
|
||||||
this.color = options.color ?? null;
|
|
||||||
this.iconUrl = IconUrlMap[options.iconName];
|
|
||||||
}
|
|
||||||
|
|
||||||
setIcon(name: IconName) {
|
|
||||||
this.iconUrl = IconUrlMap[name];
|
|
||||||
this.render().style.cssText = this.cssText();
|
|
||||||
}
|
|
||||||
|
|
||||||
cssText() {
|
|
||||||
const colorString = this.color ? `--icon-bg:${this.color}` : "";
|
|
||||||
return `-webkit-mask-image: url(${this.iconUrl}); mask-image: url(${this.iconUrl});${colorString ?? ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
build() {
|
|
||||||
const icon = <div className={"icon-view"} /> as HTMLDivElement;
|
|
||||||
icon.style.cssText = this.cssText();
|
|
||||||
return icon;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
21
src/ui/Widgets/Icon/icons.ts
Normal file
21
src/ui/Widgets/Icon/icons.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import List from "assets/svgs/list.svg";
|
||||||
|
import ArrowClockwise from "assets/svgs/arrow-clockwise.svg";
|
||||||
|
import Trash from "assets/svgs/trash.svg";
|
||||||
|
import Snowflake from "assets/svgs/snowflake.svg";
|
||||||
|
import LeftHand from "assets/svgs/LH.png";
|
||||||
|
import RightHand from "assets/svgs/RH.png";
|
||||||
|
import LeftFoot from "assets/svgs/LF.png";
|
||||||
|
import RightFoot from "assets/svgs/RF.png";
|
||||||
|
|
||||||
|
export const IconUrlMap = {
|
||||||
|
arrowClockwise: ArrowClockwise,
|
||||||
|
list: List,
|
||||||
|
trash: Trash,
|
||||||
|
snowflake: Snowflake,
|
||||||
|
lh: LeftHand,
|
||||||
|
rh: RightHand,
|
||||||
|
lf: LeftFoot,
|
||||||
|
rf: RightFoot,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type IconName = keyof typeof IconUrlMap;
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
.number-input {
|
|
||||||
text-align: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.number-input-label {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.number-input-label.top {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.number-input-label.left {
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="number"].number-input-input {
|
|
||||||
position: relative;
|
|
||||||
-webkit-appearance: textfield;
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
text-align: center;
|
|
||||||
width: 3em;
|
|
||||||
border-style: none;
|
|
||||||
border-width: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
background-color: var(--color-ui-neutral-light);
|
|
||||||
color: var(--color-p-dark);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="number"].number-input-input::-webkit-inner-spin-button,
|
|
||||||
input[type="number"].number-input-input::-webkit-outer-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.number-input-button {
|
|
||||||
border-width: 0;
|
|
||||||
background-color: var(--color-ui-neutral-dark);
|
|
||||||
color: var(--color-ui-neutral-light);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.number-input-button:hover {
|
|
||||||
background-color: var(--color-ui-neutral-dark-hover);
|
|
||||||
color: var(--color-ui-neutral-light-hover);
|
|
||||||
}
|
|
||||||
.number-input-button:active {
|
|
||||||
background-color: var(--color-ui-neutral-dark-active);
|
|
||||||
color: var(--color-ui-neutral-light-active);
|
|
||||||
}
|
|
||||||
|
|
||||||
.number-input-inc {
|
|
||||||
width: 1.4em;
|
|
||||||
border-radius: 0 0.5em 0.5em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.number-input-dec {
|
|
||||||
width: 1.4em;
|
|
||||||
border-radius: 0.5em 0 0 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.number-input.disabled {
|
|
||||||
filter: brightness(0.8);
|
|
||||||
}
|
|
||||||
.number-input.disabled input[type="number"].number-input-input {
|
|
||||||
color: var(--color-p-light);
|
|
||||||
background-color: var(--color-ui-neutral-dark);
|
|
||||||
}
|
|
||||||
.number-input.disabled .number-input-button {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.number-input.disabled .number-input-button:hover {
|
|
||||||
background-color: var(--color-ui-neutral-dark);
|
|
||||||
}
|
|
||||||
140
src/ui/Widgets/NumberInput/NumberInput.vue
Normal file
140
src/ui/Widgets/NumberInput/NumberInput.vue
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<template>
|
||||||
|
<div class="number-input" :class="{ disabled }">
|
||||||
|
<label
|
||||||
|
class="number-input-label"
|
||||||
|
:class="{
|
||||||
|
[labelPosition]: true,
|
||||||
|
visible: !!label,
|
||||||
|
}">
|
||||||
|
{{ label ?? '' }}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
class="number-input-button number-input-dec"
|
||||||
|
@click="decrement">
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="number-input-input"
|
||||||
|
:valueAsNumber="modelValue"
|
||||||
|
@blur="onBlur" />
|
||||||
|
<button
|
||||||
|
class="number-input-button number-input-inc"
|
||||||
|
@click="increment">
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
label?: string,
|
||||||
|
modelValue?: number,
|
||||||
|
labelPosition?: 'top' | 'left',
|
||||||
|
disabled?: boolean,
|
||||||
|
}>(), {
|
||||||
|
labelPosition: 'top',
|
||||||
|
label: '',
|
||||||
|
modelValue: 0,
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', newValue: number): true;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function onBlur(event: Event) {
|
||||||
|
const input = (event.target as HTMLInputElement).valueAsNumber;
|
||||||
|
if (!isNaN(input)) {
|
||||||
|
emit('update:modelValue', input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrement() {
|
||||||
|
emit('update:modelValue', props.modelValue - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function increment() {
|
||||||
|
emit('update:modelValue', props.modelValue + 1);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.number-input {
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-input-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-input-label.top {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-input-label.left {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"].number-input-input {
|
||||||
|
position: relative;
|
||||||
|
-webkit-appearance: textfield;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
text-align: center;
|
||||||
|
width: 3em;
|
||||||
|
border-style: none;
|
||||||
|
border-width: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: var(--color-ui-neutral-light);
|
||||||
|
color: var(--color-p-dark);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"].number-input-input::-webkit-inner-spin-button,
|
||||||
|
input[type="number"].number-input-input::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-input-button {
|
||||||
|
border-width: 0;
|
||||||
|
background-color: var(--color-ui-neutral-dark);
|
||||||
|
color: var(--color-ui-neutral-light);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.number-input-button:hover {
|
||||||
|
background-color: var(--color-ui-neutral-dark-hover);
|
||||||
|
color: var(--color-ui-neutral-light-hover);
|
||||||
|
}
|
||||||
|
.number-input-button:active {
|
||||||
|
background-color: var(--color-ui-neutral-dark-active);
|
||||||
|
color: var(--color-ui-neutral-light-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-input-inc {
|
||||||
|
width: 1.4em;
|
||||||
|
border-radius: 0 0.5em 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-input-dec {
|
||||||
|
width: 1.4em;
|
||||||
|
border-radius: 0.5em 0 0 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-input.disabled {
|
||||||
|
filter: brightness(0.8);
|
||||||
|
}
|
||||||
|
.number-input.disabled input[type="number"].number-input-input {
|
||||||
|
color: var(--color-p-light);
|
||||||
|
background-color: var(--color-ui-neutral-dark);
|
||||||
|
}
|
||||||
|
.number-input.disabled .number-input-button {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.number-input.disabled .number-input-button:hover {
|
||||||
|
background-color: var(--color-ui-neutral-dark);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import "./NumberInput.css";
|
|
||||||
import { Capsule, h, Rung, RungOptions } from "@djledda/ladder";
|
|
||||||
|
|
||||||
type NumberInputUINodeOptionsBase = RungOptions & {
|
|
||||||
label?: string,
|
|
||||||
initialValue?: number,
|
|
||||||
labelPosition?: "top" | "left",
|
|
||||||
}
|
|
||||||
|
|
||||||
type NumberInputUINodeOptionsIncDecInput = NumberInputUINodeOptionsBase & {
|
|
||||||
onIncrement: () => void,
|
|
||||||
onDecrement: () => void,
|
|
||||||
onNewInput: (input: number) => void,
|
|
||||||
setter?: never,
|
|
||||||
getter?: never,
|
|
||||||
};
|
|
||||||
|
|
||||||
type NumberInputUINodeOptionsGetSet = NumberInputUINodeOptionsBase & {
|
|
||||||
onIncrement?: never,
|
|
||||||
onDecrement?: never,
|
|
||||||
onNewInput?: never,
|
|
||||||
setter: (input: number) => void,
|
|
||||||
getter: () => number,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NumberInputUINodeOptions = NumberInputUINodeOptionsGetSet | NumberInputUINodeOptionsIncDecInput;
|
|
||||||
|
|
||||||
export default class NumberInputView extends Rung<HTMLDivElement> {
|
|
||||||
private labelElement = Capsule.new<HTMLLabelElement | null>(null);
|
|
||||||
private inputElement = Capsule.new<HTMLInputElement | null>(null);
|
|
||||||
private labelPosition: "top" | "left";
|
|
||||||
private value: number;
|
|
||||||
private label: string | null;
|
|
||||||
private onIncrement: (() => void) | null;
|
|
||||||
private onDecrement: (() => void) | null;
|
|
||||||
private setter: ((input: number) => void) | null;
|
|
||||||
private getter: (() => number) | null;
|
|
||||||
private onNewInput: ((input: number) => void) | null;
|
|
||||||
|
|
||||||
constructor(options: NumberInputUINodeOptions) {
|
|
||||||
super(options);
|
|
||||||
this.labelPosition = options.labelPosition ?? "top";
|
|
||||||
this.label = options.label ?? "";
|
|
||||||
this.value = options.initialValue ?? 0;
|
|
||||||
this.onDecrement = options.onDecrement ?? null;
|
|
||||||
this.setter = options.setter ?? null;
|
|
||||||
this.getter = options.getter ?? null;
|
|
||||||
this.onIncrement = options.onIncrement ?? null;
|
|
||||||
this.onNewInput = options.onNewInput ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLabel(newLabel: string | null): void {
|
|
||||||
if (!this.labelElement.val) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (newLabel !== null) {
|
|
||||||
this.label = newLabel;
|
|
||||||
this.labelElement.val.innerText = newLabel;
|
|
||||||
this.labelElement.val.classList.add("visible");
|
|
||||||
} else {
|
|
||||||
this.label = newLabel;
|
|
||||||
this.labelElement.val.innerText = "";
|
|
||||||
this.labelElement.val.classList.remove("visible");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
disable(): void {
|
|
||||||
this.render().classList.add("disabled");
|
|
||||||
this.inputElement.val!.disabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
enable(): void {
|
|
||||||
this.render().classList.remove("disabled");
|
|
||||||
this.inputElement.val!.disabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
setValue(value: number): void {
|
|
||||||
this.value = value;
|
|
||||||
this.inputElement.val!.valueAsNumber = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
build(): HTMLDivElement {
|
|
||||||
const labelClasses = ["number-input-label", this.labelPosition];
|
|
||||||
if (this.label !== null) {
|
|
||||||
labelClasses.push("visible");
|
|
||||||
}
|
|
||||||
return <div className={"number-input"}>
|
|
||||||
<label
|
|
||||||
classes={labelClasses}
|
|
||||||
saveTo={this.labelElement}>
|
|
||||||
{this.label ?? ""}
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
classes={["number-input-button", "number-input-dec"]}
|
|
||||||
onclick={() => {
|
|
||||||
if (this.onDecrement) {
|
|
||||||
this.onDecrement();
|
|
||||||
} else if (this.setter && this.getter) {
|
|
||||||
this.setter(this.getter() - 1);
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
-
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type={"number"}
|
|
||||||
saveTo={this.inputElement}
|
|
||||||
className={"number-input-input"}
|
|
||||||
valueAsNumber={this.value}
|
|
||||||
onblur={(event: Event) => {
|
|
||||||
const input = (event.target as HTMLInputElement).valueAsNumber;
|
|
||||||
if (!isNaN(input)) {
|
|
||||||
if (this.onNewInput) {
|
|
||||||
this.onNewInput(input);
|
|
||||||
} else if (this.setter) {
|
|
||||||
this.setter(input);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}} />
|
|
||||||
<button
|
|
||||||
classes={["number-input-button", "number-input-inc"]}
|
|
||||||
onclick={() => {
|
|
||||||
if (this.onIncrement) {
|
|
||||||
this.onIncrement();
|
|
||||||
} else if (this.setter && this.getter) {
|
|
||||||
this.setter(this.getter() + 1);
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div> as HTMLDivElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,26 @@
|
|||||||
|
:root {
|
||||||
|
--color-ui-accent: #00b3ba;
|
||||||
|
--color-ui-accent-hover: #00c1c9;
|
||||||
|
--color-ui-accent-active: #008e93;
|
||||||
|
--color-ui-neutral-light: #fdfdfe;
|
||||||
|
--color-ui-neutral-light-hover: #fdfdfe;
|
||||||
|
--color-ui-neutral-light-active: #fdfdfe;
|
||||||
|
--color-ui-neutral-dark: #8b8b8b;
|
||||||
|
--color-ui-neutral-dark-hover: #a1a1a1;
|
||||||
|
--color-ui-neutral-dark-active: #c1c1c1;
|
||||||
|
--color-bg-light: #464646;
|
||||||
|
--color-bg-medium: #323232;
|
||||||
|
--color-bg-dark: #282828;
|
||||||
|
--color-p-light: #fafafa;
|
||||||
|
--color-p-light-hover: #fafafa;
|
||||||
|
--color-p-light-active: #fafafa;
|
||||||
|
--color-p-dark: #282828;
|
||||||
|
--color-p-dark-hover: #464646;
|
||||||
|
--color-p-dark-active: #464646;
|
||||||
|
--color-title-light: #fafafa;
|
||||||
|
--color-title-dark: #282828;
|
||||||
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
8
tsconfig.config.json
Normal file
8
tsconfig.config.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.node.json",
|
||||||
|
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "playwright.config.*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.web.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"noImplicitAny": true,
|
"noImplicitAny": true,
|
||||||
@@ -6,6 +7,7 @@
|
|||||||
"target": "esnext",
|
"target": "esnext",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "Node",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
@@ -20,5 +22,10 @@
|
|||||||
"include": [
|
"include": [
|
||||||
"./src/**/*",
|
"./src/**/*",
|
||||||
"./assets/**/*"
|
"./assets/**/*"
|
||||||
|
],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.config.json"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
import { defineConfig } from "vite";
|
import { fileURLToPath, URL } from 'node:url';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import devEnv from './dev.env';
|
||||||
|
import prodEnv from './prod.env';
|
||||||
|
|
||||||
|
const { DEVELOPMENT, BASE_URL } = process.env.DEV ? devEnv : prodEnv;
|
||||||
|
|
||||||
async function createConfig() {
|
export default defineConfig({
|
||||||
const { DEVELOPMENT, BASE_URL } = (await (process.env.DEV ? import("./dev.env") : import("./prod.env"))).default;
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
return defineConfig({
|
alias: {
|
||||||
resolve: {
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
alias: {
|
'assets': fileURLToPath(new URL('./assets', import.meta.url)),
|
||||||
"@": "/src",
|
|
||||||
"assets": "/assets",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
base: BASE_URL,
|
|
||||||
build: {
|
|
||||||
minify: !DEVELOPMENT,
|
|
||||||
target: DEVELOPMENT ? "modules" : "es6",
|
|
||||||
sourcemap: DEVELOPMENT,
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
port: 3000,
|
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
}
|
base: BASE_URL,
|
||||||
|
build: {
|
||||||
export default createConfig();
|
minify: !DEVELOPMENT,
|
||||||
|
target: DEVELOPMENT ? "modules" : "es6",
|
||||||
|
sourcemap: DEVELOPMENT,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
port: 3000,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user