diff --git a/.stylelintrc.json b/.stylelintrc.json new file mode 100644 index 0000000..ee85d9b --- /dev/null +++ b/.stylelintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "stylelint-config-standard" +} \ No newline at end of file diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 5d87383..73bea2a 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -1,9 +1,11 @@ import esbuild from "esbuild"; import process from "process"; -import builtins from 'builtin-modules' +import builtins from 'builtin-modules'; +import vuePlugin from 'esbuild-plugin-vue3'; +import svgPlugin from 'esbuild-plugin-svg'; const banner = -`/* + `/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD if you want to view the source, please visit the github repository of this plugin */ @@ -15,9 +17,10 @@ esbuild.build({ banner: { js: banner, }, - entryPoints: ['main.ts'], + entryPoints: ['src/main.ts'], bundle: true, external: ['obsidian', 'electron', ...builtins], + plugins: [vuePlugin(), svgPlugin()], format: 'cjs', watch: !prod, target: 'es2016', diff --git a/main.css b/main.css new file mode 100644 index 0000000..a07cce5 --- /dev/null +++ b/main.css @@ -0,0 +1,5 @@ +/* sfc-style:/Users/curt/obsidian/Primary Vault/.obsidian/plugins/obsidian-metronome-plugin/src/components/Metronome.vue?type=style&index=0 */ +.example { + color: red; +} +/*# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsic2ZjLXN0eWxlOi9Vc2Vycy9jdXJ0L29ic2lkaWFuL1ByaW1hcnkgVmF1bHQvLm9ic2lkaWFuL3BsdWdpbnMvb2JzaWRpYW4tbWV0cm9ub21lLXBsdWdpbi9zcmMvY29tcG9uZW50cy9NZXRyb25vbWUudnVlP3R5cGU9c3R5bGUmaW5kZXg9MCJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiXG4uZXhhbXBsZSB7XG4gIGNvbG9yOiByZWQ7XG59XG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQ0E7QUFDRTtBQUFBOyIsCiAgIm5hbWVzIjogW10KfQo= */ diff --git a/main.ts b/main.ts deleted file mode 100644 index 9b24b2f..0000000 --- a/main.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian'; - -// Remember to rename these classes and interfaces! - -interface MyPluginSettings { - mySetting: string; -} - -const DEFAULT_SETTINGS: MyPluginSettings = { - mySetting: 'default' -} - -export default class MyPlugin extends Plugin { - settings: MyPluginSettings; - - async onload() { - await this.loadSettings(); - - // This creates an icon in the left ribbon. - const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => { - // Called when the user clicks the icon. - new Notice('This is a notice!'); - }); - // Perform additional things with the ribbon - ribbonIconEl.addClass('my-plugin-ribbon-class'); - - // This adds a status bar item to the bottom of the app. Does not work on mobile apps. - const statusBarItemEl = this.addStatusBarItem(); - statusBarItemEl.setText('Status Bar Text'); - - // This adds a simple command that can be triggered anywhere - this.addCommand({ - id: 'open-sample-modal-simple', - name: 'Open sample modal (simple)', - callback: () => { - new SampleModal(this.app).open(); - } - }); - // This adds an editor command that can perform some operation on the current editor instance - this.addCommand({ - id: 'sample-editor-command', - name: 'Sample editor command', - editorCallback: (editor: Editor, view: MarkdownView) => { - console.log(editor.getSelection()); - editor.replaceSelection('Sample Editor Command'); - } - }); - // This adds a complex command that can check whether the current state of the app allows execution of the command - this.addCommand({ - id: 'open-sample-modal-complex', - name: 'Open sample modal (complex)', - checkCallback: (checking: boolean) => { - // Conditions to check - const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); - if (markdownView) { - // If checking is true, we're simply "checking" if the command can be run. - // If checking is false, then we want to actually perform the operation. - if (!checking) { - new SampleModal(this.app).open(); - } - - // This command will only show up in Command Palette when the check function returns true - return true; - } - } - }); - - // This adds a settings tab so the user can configure various aspects of the plugin - this.addSettingTab(new SampleSettingTab(this.app, this)); - - // If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin) - // Using this function will automatically remove the event listener when this plugin is disabled. - this.registerDomEvent(document, 'click', (evt: MouseEvent) => { - console.log('click', evt); - }); - - // When registering intervals, this function will automatically clear the interval when the plugin is disabled. - this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000)); - } - - onunload() { - - } - - async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); - } - - async saveSettings() { - await this.saveData(this.settings); - } -} - -class SampleModal extends Modal { - constructor(app: App) { - super(app); - } - - onOpen() { - const {contentEl} = this; - contentEl.setText('Woah!'); - } - - onClose() { - const {contentEl} = this; - contentEl.empty(); - } -} - -class SampleSettingTab extends PluginSettingTab { - plugin: MyPlugin; - - constructor(app: App, plugin: MyPlugin) { - super(app, plugin); - this.plugin = plugin; - } - - display(): void { - const {containerEl} = this; - - containerEl.empty(); - - containerEl.createEl('h2', {text: 'Settings for my awesome plugin.'}); - - new Setting(containerEl) - .setName('Setting #1') - .setDesc('It\'s a secret') - .addText(text => text - .setPlaceholder('Enter your secret') - .setValue(this.plugin.settings.mySetting) - .onChange(async (value) => { - console.log('Secret: ' + value); - this.plugin.settings.mySetting = value; - await this.plugin.saveSettings(); - })); - } -} diff --git a/manifest.json b/manifest.json index 30ec656..428d48f 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,10 @@ { "id": "obsidian-sample-plugin", - "name": "Sample Plugin", - "version": "1.0.1", + "name": "Metronome", + "version": "0.1", "minAppVersion": "0.12.0", "description": "This is a sample plugin for Obsidian. This plugin demonstrates some of the capabilities of the Obsidian API.", "author": "Obsidian", "authorUrl": "https://obsidian.md", "isDesktopOnly": false -} +} \ No newline at end of file diff --git a/package.json b/package.json index 11071f9..ffe6906 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,17 @@ "@types/node": "^16.11.6", "@typescript-eslint/eslint-plugin": "^5.2.0", "@typescript-eslint/parser": "^5.2.0", + "@vapurrmaid/bpm": "^0.1.8", "builtin-modules": "^3.2.0", - "esbuild": "0.13.12", + "esbuild": "^0.13.12", + "esbuild-plugin-svg": "^0.1.0", + "esbuild-plugin-vue3": "^0.3.0", "obsidian": "^0.12.17", + "stylelint": "^14.1.0", + "stylelint-config-standard": "^24.0.0", + "tone": "^14.8.32", "tslib": "2.3.1", - "typescript": "4.4.4" + "typescript": "4.4.4", + "vue": "^3.2.26" } -} +} \ No newline at end of file diff --git a/src/assets/icons/volume-mute.svg b/src/assets/icons/volume-mute.svg new file mode 100644 index 0000000..12659d9 --- /dev/null +++ b/src/assets/icons/volume-mute.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/icons/volume-up.svg b/src/assets/icons/volume-up.svg new file mode 100644 index 0000000..3840310 --- /dev/null +++ b/src/assets/icons/volume-up.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/components/Metronome.vue b/src/components/Metronome.vue new file mode 100644 index 0000000..f98b41d --- /dev/null +++ b/src/components/Metronome.vue @@ -0,0 +1,58 @@ + + + diff --git a/src/components/SoundToggle.vue b/src/components/SoundToggle.vue new file mode 100644 index 0000000..c051751 --- /dev/null +++ b/src/components/SoundToggle.vue @@ -0,0 +1,39 @@ + + + \ No newline at end of file diff --git a/src/hooks/useTick.ts b/src/hooks/useTick.ts new file mode 100644 index 0000000..95b456a --- /dev/null +++ b/src/hooks/useTick.ts @@ -0,0 +1,86 @@ +import { computed, Ref, ref, watch } from "vue"; +import { Meter } from "../models/Meter"; + +export function useTick(meter: Ref) { + const beatCount = ref(-1); + const doBeat = () => beatCount.value++; + const resetAfterNextBeat = ref(false); + + const resetTick = () => (resetAfterNextBeat.value = true); + + const currentBeatIsTick = computed( + () => + // Every beat is a tick if we don't have a valid meter + !meter.value || + !meter.value.isValid() || + beatCount.value % meter.value.upper === 0 + ); + + const currentBeatIsTickAlternate = computed( + () => + meter.value && + meter.value.isValid() && + meter.value.upper >= 6 && + beatCount.value % (meter.value.upper / 2) === 0 + ); + + const currentBeatIsTock = computed( + () => !currentBeatIsTick.value && !currentBeatIsTickAlternate.value + ); + + const onTickCallbacks: CallableFunction[] = []; + const onTickAlternateCallbacks: CallableFunction[] = []; + const onTockCallbacks: CallableFunction[] = []; + + const onTick = (callback: CallableFunction) => { + onTickCallbacks.push(callback); + }; + + const onTickAlternate = (callback: CallableFunction) => + onTickAlternateCallbacks.push(callback); + + const onTock = (callback: CallableFunction) => + onTockCallbacks.push(callback); + + watch(beatCount, () => { + if (resetAfterNextBeat.value) { + beatCount.value = 0; + resetAfterNextBeat.value = false; + } + }); + + watch([currentBeatIsTick, beatCount], ([currentBeatIsTick]) => { + if (resetAfterNextBeat.value) { + return; + } + + if (currentBeatIsTick) { + onTickCallbacks.forEach((callback) => callback()); + } + }); + + watch( + [currentBeatIsTickAlternate, beatCount], + ([currentBeatIsTickAlternate]) => { + if (resetAfterNextBeat.value) { + return; + } + + if (currentBeatIsTickAlternate) { + onTickAlternateCallbacks.forEach((callback) => callback()); + } + } + ); + + watch([currentBeatIsTock, beatCount], ([currentBeatIsTock]) => { + if (resetAfterNextBeat.value) { + return; + } + + if (currentBeatIsTock) { + onTockCallbacks.forEach((callback) => callback()); + } + }); + + return { doBeat, onTick, onTickAlternate, onTock, resetTick }; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..2221af8 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,170 @@ +import { MarkdownPostProcessor, MarkdownRenderChild, Plugin } from "obsidian"; +import Metronome from "./components/Metronome.vue"; +import { createApp } from "vue"; +import { Meter } from "./models/Meter"; +import { MetronomeSize, isMetronomeSize } from "./models/MetronomeSize"; + +interface MetronomePluginSettings { + mySetting: string; +} + +export interface MetronomeCodeBlockParameters { + bpm: number; + sound: boolean; + meter?: Meter; + size: MetronomeSize; +} + +const DEFAULT_SETTINGS: MetronomePluginSettings = { + mySetting: "default", +}; + +export default class MetronomePlugin extends Plugin { + settings: MetronomePluginSettings; + processors: MarkdownPostProcessor[]; + + async onload() { + await this.loadSettings(); + + this.registerMarkdownCodeBlockProcessor( + `metronome`, + (src, el, context) => { + const parameters = this.getCodeBlockParameters(src); + + const div = document.createElement("div"); + + createApp(Metronome, { + ...parameters, + }).mount(div); + + const child = new MarkdownRenderChild(div); + context.addChild(child); + el.append(div); + } + ); + + // console.log("processor", processor); + + // // This adds an editor command that can perform some operation on the current editor instance + // this.addCommand({ + // id: "add-metronome", + // name: "Add metronome", + // editorCallback: (editor: Editor, view: MarkdownView) => { + // editor.replaceSelection("\n```metronome\nasdf```"); + // }, + // }); + + // // This adds a complex command that can check whether the current state of the app allows execution of the command + // this.addCommand({ + // id: "open-sample-modal-complex", + // name: "Open sample modal (complex)", + // checkCallback: (checking: boolean) => { + // // Conditions to check + // const markdownView = + // this.app.workspace.getActiveViewOfType(MarkdownView); + // if (markdownView) { + // // If checking is true, we're simply "checking" if the command can be run. + // // If checking is false, then we want to actually perform the operation. + // if (!checking) { + // new SampleModal(this.app).open(); + // } + + // // This command will only show up in Command Palette when the check function returns true + // return true; + // } + // }, + // }); + + // This adds a settings tab so the user can configure various aspects of the plugin + // this.addSettingTab(new SampleSettingTab(this.app, this)); + + // If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin) + // Using this function will automatically remove the event listener when this plugin is disabled. + // this.registerDomEvent(document, "click", (evt: MouseEvent) => { + // console.log("click", evt); + // }); + + // When registering intervals, this function will automatically clear the interval when the plugin is disabled. + // this.registerInterval( + // window.setInterval(() => console.log("setInterval"), 5 * 60 * 1000) + // ); + } + + onunload() {} + + async loadSettings() { + this.settings = Object.assign( + {}, + DEFAULT_SETTINGS, + await this.loadData() + ); + } + + async saveSettings() { + await this.saveData(this.settings); + } + + getCodeBlockParameters(src: string): MetronomeCodeBlockParameters { + const values: { [key: string]: any } = {}; + + src.split("\n") + .map((line: string) => line.split(":")) + .forEach(([key, value]: [string, string]) => { + values[key] = (value || "").trim(); + }); + + return { + bpm: parseFloat(values.bpm) || null, + sound: values.sound === "yes", + meter: values.meter ? Meter.fromString(values.meter) : null, + size: isMetronomeSize(values.size) ? values.size : null, + }; + } +} + +// class SampleModal extends Modal { +// constructor(app: App) { +// super(app); +// } + +// onOpen() { +// const { contentEl } = this; +// contentEl.setText("Woah!"); +// } + +// onClose() { +// const { contentEl } = this; +// contentEl.empty(); +// } +// } + +// class SampleSettingTab extends PluginSettingTab { +// plugin: MetronomePlugin; + +// constructor(app: App, plugin: MetronomePlugin) { +// super(app, plugin); +// this.plugin = plugin; +// } + +// display(): void { +// const { containerEl } = this; + +// containerEl.empty(); + +// containerEl.createEl("h2", { text: "Settings for my awesome plugin." }); + +// new Setting(containerEl) +// .setName("Setting #1") +// .setDesc("It's a secret") +// .addText((text) => +// text +// .setPlaceholder("Enter your secret") +// .setValue(this.plugin.settings.mySetting) +// .onChange(async (value) => { +// console.log("Secret: " + value); +// this.plugin.settings.mySetting = value; +// await this.plugin.saveSettings(); +// }) +// ); +// } +// } diff --git a/src/models/Meter.ts b/src/models/Meter.ts new file mode 100644 index 0000000..5f6626e --- /dev/null +++ b/src/models/Meter.ts @@ -0,0 +1,55 @@ +import { NoteDuration } from "@vapurrmaid/bpm"; + +export class Meter { + upper: number; + lower: number; + + constructor(upper: number, lower: number) { + this.upper = upper; + this.lower = lower; + } + + noteDuration(): NoteDuration { + switch (this.lower) { + case 64: + return "sixtyfourth"; + case 32: + return "thirtysecondth"; + case 16: + return "sixteenth"; + case 8: + return "eigth"; + case 4: + return "quarter"; + case 2: + return "half"; + case 1: + return "whole"; + default: + return null; + } + } + + isValid() { + return ( + !isNaN(this.upper) && + !isNaN(this.lower) && + this.upper > 0 && + this.lower > 0 + ); + } + + toString() { + return this.isValid() && this.noteDuration() + ? `${this.upper}/${this.lower}` + : "Invalid or unsupported meter"; + } + + static fromString(unparsedMeterString: string) { + const meterSplit = (unparsedMeterString || "").split("/"); + const upper = parseInt(meterSplit?.[0]); + const lower = parseInt(meterSplit?.[1]); + + return new Meter(upper, lower); + } +} diff --git a/src/models/MetronomeSize.ts b/src/models/MetronomeSize.ts new file mode 100644 index 0000000..ad75f5e --- /dev/null +++ b/src/models/MetronomeSize.ts @@ -0,0 +1,5 @@ +export type MetronomeSize = "small" | "medium" | "large"; + +export function isMetronomeSize(inputSize: string): inputSize is MetronomeSize { + return ["small", "medium", "large"].includes(inputSize); +} diff --git a/src/sounds.ts b/src/sounds.ts new file mode 100644 index 0000000..fbaf151 --- /dev/null +++ b/src/sounds.ts @@ -0,0 +1,21 @@ +window.TONE_SILENCE_LOGGING = true; + +import * as Tone from "tone/build/esm"; + +// const synth = new Tone.Synth().toDestination(); + +const whiteNoise = new Tone.NoiseSynth({ + noise: { type: "white" }, +}).toDestination(); + +const brownNoise = new Tone.NoiseSynth({ + noise: { type: "brown" }, +}).toDestination(); + +const pinkNoise = new Tone.NoiseSynth({ + noise: { type: "pink" }, +}).toDestination(); + +export const tick = () => whiteNoise.triggerAttackRelease("8n"); +export const tock = () => brownNoise.triggerAttackRelease("8n"); +export const tickUpbeat = () => pinkNoise.triggerAttackRelease("8n"); diff --git a/styles.css b/styles.css index cfd0fd7..9c0049b 100644 --- a/styles.css +++ b/styles.css @@ -1,4 +1,85 @@ -/* Sets all the text color to red! */ -body { - color: red; +.metronome-metronome { + border-radius: 0.25rem; + animation: metronome-pulse var(--metronome-duration) infinite; + margin: 15px 0; + position: relative; + display: flex; + align-items: center; + justify-content: flex-end; + padding: 0.15rem; +} + +/* Sizes: ["small" (default), "medium", "large"] */ +.metronome-metronome[data-size="medium"] { + height: 4rem; + align-items: flex-end; +} + +.metronome-metronome[data-size="large"] { + height: 15rem; + align-items: flex-end; + padding: 0.5rem; +} + +.metronome-metronome button { + /* Reset button styles */ + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + outline: 0 !important; + box-shadow: none !important; + border-radius: 0; +} + +.metronome-metronome .sound-toggle { + opacity: 0.5; + border-radius: 0.2rem; + height: 1.25rem; + width: 1.25rem; + display: flex; + align-items: center; + justify-content: center; +} + +.metronome-metronome .sound-toggle div { + display: flex; + align-items: center; + justify-content: center; +} + +.metronome-description { + font-weight: bold; + margin-right: auto; + font-size: 0.68rem; + padding:0 0.25rem; + opacity: 0.5; +} + +.metronome-metronome[data-size="medium"] .metronome-description, +.metronome-metronome[data-size="large"] .metronome-description { + padding: 0; +} + + +.metronome-metronome .sound-toggle:hover, +.metronome-metronome .sound-toggle:active { + background: rgb(100 100 100 / 30%); +} + +.metronome-metronome .sound-toggle:hover, +.metronome-metronome .sound-toggle:active, +.metronome-metronome .sound-toggle.enabled { + opacity: 1; +} + +@keyframes metronome-pulse { + 0% { + background: var(--tick-color); + } + + 100% { + background: rgb(0 0 0 / 10%); + } }