From a9955de8e219e839f17a4400b89f4e2789b1d634 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Thu, 3 Jun 2021 11:47:07 +0800 Subject: [PATCH] Update front-end Implement input in form. --- packages/app-web/package.json | 6 +- .../molecules/forms/ActionButton/index.tsx | 12 +- .../forms/NumericInput/index.test.tsx | 15 + .../molecules/forms/NumericInput/index.tsx | 86 +++++ .../molecules/forms/TextArea/index.tsx | 15 +- .../forms/ToggleButton/index.test.tsx | 19 + .../molecules/forms/ToggleButton/index.tsx | 124 +++++++ .../organisms/forms/CreateRingtone/index.tsx | 324 +++++++++++++++++- .../templates/CreateRingtone/index.tsx | 18 +- .../app-web/src/modules/ringtone/client.ts | 4 +- packages/app-web/src/utils/format/keyData.ts | 83 +++++ packages/app-web/src/utils/format/song.ts | 33 ++ .../app-web/src/utils/sound/SoundManager.ts | 18 + .../app-web/src/utils/sound/WaveOscillator.ts | 26 ++ packages/app-web/yarn.lock | 48 ++- packages/library-common/src/index.ts | 2 + 16 files changed, 799 insertions(+), 34 deletions(-) create mode 100644 packages/app-web/src/components/molecules/forms/NumericInput/index.test.tsx create mode 100644 packages/app-web/src/components/molecules/forms/NumericInput/index.tsx create mode 100644 packages/app-web/src/components/molecules/forms/ToggleButton/index.test.tsx create mode 100644 packages/app-web/src/components/molecules/forms/ToggleButton/index.tsx create mode 100644 packages/app-web/src/utils/format/keyData.ts create mode 100644 packages/app-web/src/utils/format/song.ts create mode 100644 packages/app-web/src/utils/sound/SoundManager.ts create mode 100644 packages/app-web/src/utils/sound/WaveOscillator.ts diff --git a/packages/app-web/package.json b/packages/app-web/package.json index fcd12f6..74e16af 100644 --- a/packages/app-web/package.json +++ b/packages/app-web/package.json @@ -11,8 +11,10 @@ }, "dependencies": { "@auth0/nextjs-auth0": "^1.3.1", - "@tesseract-design/viewfinder": "^0.1.1", - "@theoryofnekomata/formxtr": "^0.1.2", + "@theoryofnekomata/formxtra": "^0.2.3", + "@theoryofnekomata/react-musical-keyboard": "^1.1.4", + "@theoryofnekomata/viewfinder": "0.2.4", + "mem": "^8.1.1", "next": "10.2.0", "react": "17.0.2", "react-dom": "17.0.2", diff --git a/packages/app-web/src/components/molecules/forms/ActionButton/index.tsx b/packages/app-web/src/components/molecules/forms/ActionButton/index.tsx index cedb38d..330a8a9 100644 --- a/packages/app-web/src/components/molecules/forms/ActionButton/index.tsx +++ b/packages/app-web/src/components/molecules/forms/ActionButton/index.tsx @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import {FC, ReactChild} from 'react'; +import {FC, MouseEventHandler, ReactChild} from 'react'; const Base = styled('div')({ height: '3rem', @@ -22,7 +22,8 @@ const Base = styled('div')({ }) const ClickArea = styled('button')({ - display: 'block', + display: 'grid', + placeContent: 'center', width: '100%', height: '100%', margin: 0, @@ -36,6 +37,11 @@ const ClickArea = styled('button')({ textTransform: 'uppercase', fontWeight: 'bolder', position: 'relative', + lineHeight: 0, + cursor: 'pointer', + ':disabled': { + cursor: 'not-allowed', + } }) const VARIANTS = { @@ -57,6 +63,8 @@ type Props = { type?: 'button' | 'reset' | 'submit', block?: boolean, variant?: keyof typeof VARIANTS, + onClick?: MouseEventHandler, + disabled?: boolean, } const ActionButton: FC = ({ diff --git a/packages/app-web/src/components/molecules/forms/NumericInput/index.test.tsx b/packages/app-web/src/components/molecules/forms/NumericInput/index.test.tsx new file mode 100644 index 0000000..0eafa65 --- /dev/null +++ b/packages/app-web/src/components/molecules/forms/NumericInput/index.test.tsx @@ -0,0 +1,15 @@ +import {render, screen} from '@testing-library/react' +import TextInput from '.' + +describe('single-line text input component', () => { + it('should contain a text input element', () => { + render() + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('should acquire a descriptive label', () => { + const label = 'foo' + render() + expect(screen.getByLabelText(label)).toBeInTheDocument() + }) +}) diff --git a/packages/app-web/src/components/molecules/forms/NumericInput/index.tsx b/packages/app-web/src/components/molecules/forms/NumericInput/index.tsx new file mode 100644 index 0000000..45dcebb --- /dev/null +++ b/packages/app-web/src/components/molecules/forms/NumericInput/index.tsx @@ -0,0 +1,86 @@ +import {ChangeEventHandler, FC} from 'react'; +import styled from 'styled-components' + +const Base = styled('div')({ + height: '3rem', + borderRadius: '0.25rem', + overflow: 'hidden', + position: 'relative', + backgroundColor: 'var(--color-bg, white)', + '::before': { + content: "''", + borderWidth: 1, + borderStyle: 'solid', + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + borderRadius: 'inherit', + boxSizing: 'border-box', + }, +}) + +const ClickArea = styled('label')({ + position: 'relative', + height: '100%', +}) + +const Label = styled('span')({ + position: 'absolute', + left: -999999, +}) + +const Input = styled('input')({ + display: 'block', + width: '100%', + height: '100%', + margin: 0, + padding: '0 1rem', + boxSizing: 'border-box', + font: 'inherit', + border: 0, + backgroundColor: 'transparent', + color: 'inherit', + outline: 0, +}) + +type Props = { + label: string, + name: string, + className?: string, + block?: boolean, + placeholder?: string, + defaultValue?: string | number | readonly string[], + onChange?: ChangeEventHandler, + disabled?: boolean, +} + +const NumericInput: FC = ({ + label, + className, + block, + ...etcProps +}) => { + return ( + + + + + + + ) +} + +export default NumericInput diff --git a/packages/app-web/src/components/molecules/forms/TextArea/index.tsx b/packages/app-web/src/components/molecules/forms/TextArea/index.tsx index 1b330c1..85784a2 100644 --- a/packages/app-web/src/components/molecules/forms/TextArea/index.tsx +++ b/packages/app-web/src/components/molecules/forms/TextArea/index.tsx @@ -1,4 +1,4 @@ -import {FC} from 'react' +import {ChangeEventHandler, CSSProperties, FC, FocusEventHandler, forwardRef, PropsWithoutRef} from 'react'; import styled from 'styled-components' const Base = styled('div')({ @@ -51,14 +51,19 @@ type Props = { block?: boolean, placeholder?: string, defaultValue?: string | number | readonly string[], + readOnly?: boolean, + onChange?: ChangeEventHandler, + onBlur?: FocusEventHandler, + style?: CSSProperties, } -const TextArea: FC = ({ +const TextArea = forwardRef>(({ label, className, block, + style = {}, ...etcProps -}) => { +}, ref) => { return ( = ({ ) -} +}) export default TextArea diff --git a/packages/app-web/src/components/molecules/forms/ToggleButton/index.test.tsx b/packages/app-web/src/components/molecules/forms/ToggleButton/index.test.tsx new file mode 100644 index 0000000..6105ea3 --- /dev/null +++ b/packages/app-web/src/components/molecules/forms/ToggleButton/index.test.tsx @@ -0,0 +1,19 @@ +import {render, screen} from '@testing-library/react' +import ActionButton from '.' + +describe('button component for triggering actions', () => { + it('should render a button element with a no-op action', () => { + render() + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + describe.each(['button', 'reset', 'submit'] as const)('on %p action', (type) => { + beforeEach(() => { + render() + }) + + it('should render a button element with a submit action', () => { + expect(screen.getByRole('button')).toHaveAttribute('type', type) + }) + }) +}) diff --git a/packages/app-web/src/components/molecules/forms/ToggleButton/index.tsx b/packages/app-web/src/components/molecules/forms/ToggleButton/index.tsx new file mode 100644 index 0000000..7f4358a --- /dev/null +++ b/packages/app-web/src/components/molecules/forms/ToggleButton/index.tsx @@ -0,0 +1,124 @@ +import styled from 'styled-components'; +import {ChangeEventHandler, FC, ReactChild} from 'react'; + +const Base = styled('div')({ + height: '3rem', + borderRadius: '0.25rem', + overflow: 'hidden', + position: 'relative', + '::after': { + content: "''", + borderWidth: 1, + borderStyle: 'solid', + borderColor: 'inherit', + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + borderRadius: 'inherit', + boxSizing: 'border-box', + pointerEvents: 'none', + }, +}) + +const ClickArea = styled('label')({ + display: 'block', + width: '100%', + height: '100%', + margin: 0, + padding: '0 1rem', + boxSizing: 'border-box', + font: 'inherit', + border: 0, + backgroundColor: 'transparent', + color: 'inherit', + outline: 0, + textTransform: 'uppercase', + fontWeight: 'bolder', + position: 'relative', + borderRadius: 'inherit', +}) + +const Input = styled('input')({ + position: 'absolute', + left: -999999, +}) + +const ButtonWrapper = styled('span')({ + display: 'grid', + placeContent: 'center', + borderRadius: 'inherit', + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + lineHeight: 0, + cursor: 'pointer', + [`${Input}:disabled + &`]: { + cursor: 'not-allowed', + }, + [`${Input}:checked + &`]: { + backgroundColor: 'Highlight !important', + }, +}) + +const VARIANTS = { + default: { + backgroundColor: 'var(--color-bg, white)', + borderColor: 'var(--color-fg, black)', + color: 'var(--color-fg, black)', + }, + primary: { + backgroundColor: 'var(--color-fg, black)', + borderColor: 'var(--color-fg, black)', + color: 'var(--color-bg, white)', + }, +} + +type Props = { + children?: ReactChild, + className?: string, + type?: 'checkbox' | 'radio', + block?: boolean, + variant?: keyof typeof VARIANTS, + name?: string, + value?: string, + defaultChecked?: boolean, + onChange?: ChangeEventHandler, + disabled?: boolean, +} + +const ToggleButton: FC = ({ + children, + className, + type = 'checkbox', + block, + variant = 'default', + ...etcProps +}) => { + return ( + + + + + {children} + + + + ) +} + +export default ToggleButton diff --git a/packages/app-web/src/components/organisms/forms/CreateRingtone/index.tsx b/packages/app-web/src/components/organisms/forms/CreateRingtone/index.tsx index 7a28f17..3213707 100644 --- a/packages/app-web/src/components/organisms/forms/CreateRingtone/index.tsx +++ b/packages/app-web/src/components/organisms/forms/CreateRingtone/index.tsx @@ -1,15 +1,52 @@ -import {FC, FormEventHandler} from 'react' -import {models} from '@tonality/library-common' -import styled from 'styled-components' -import TextInput from '../../../molecules/forms/TextInput' -import TextArea from '../../../molecules/forms/TextArea' -import ActionButton from '../../../molecules/forms/ActionButton' +import {CSSProperties, FC, FormEventHandler, useEffect, useRef, useState} from 'react'; +import {models} from '@tonality/library-common'; +import styled from 'styled-components'; +import MusicalKeyboard from '@theoryofnekomata/react-musical-keyboard'; +import getFormValues from '@theoryofnekomata/formxtra'; +import TextInput from '../../../molecules/forms/TextInput'; +import TextArea from '../../../molecules/forms/TextArea'; +import ActionButton from '../../../molecules/forms/ActionButton'; +import {convertToRawDuration, formatKey} from '../../../../utils/format/keyData'; +import ToggleButton from '../../../molecules/forms/ToggleButton'; +import NumericInput from '../../../molecules/forms/NumericInput'; +import WaveOscillator from '../../../../utils/sound/WaveOscillator'; +import {parseSong} from '../../../../utils/format/song'; const Form = styled('form')({ display: 'grid', gap: '1rem', +}); + +const Toolbar = styled('div')({ + display: 'grid', + gridTemplateColumns: '6fr 1fr 1fr 1fr', + gap: '1rem', + fontSize: '1.5rem', }) +const DurationSelector = styled('div')({ + display: 'grid', + gridTemplateColumns: 'repeat(6, 1fr)', +}) + +const NOTE_GLYPHS = { + 1: '𝅝', + 2: '𝅗𝅥', + 4: '♩', + 8: '♪', + 16: '𝅘𝅥𝅯', + 32: '𝅘𝅥𝅰', +}; + +const REST_GLYPHS = { + 1: '𝄻', + 2: '𝄼', + 4: '𝄽', + 8: '𝄾', + 16: '𝄿', + 32: '𝅀', +}; + type Props = { onSubmit?: FormEventHandler, action?: string, @@ -23,12 +60,149 @@ const CreateRingtoneForm: FC = ({ labels, defaultValues = {}, }) => { + const [hydrated, setHydrated] = useState(false); + const [noteGlyph, setNoteGlyph] = useState(NOTE_GLYPHS[4]); + const [restGlyph, setRestGlyph] = useState(REST_GLYPHS[4]); + const [playTimestamp, setPlayTimestamp] = useState(0) + const [playing, setPlaying] = useState(false) + const [currentTime, setCurrentTime] = useState(0) + const dataRef = useRef(null); + const formRef = useRef(null); + const songRef = useRef({ + notes: [], + duration: 0, + index: 0, + }); + + const addNote = (args) => { + if (!(args.length > 0 && dataRef.current && formRef.current)) { + return; + } + const values = getFormValues(formRef.current); + const isDotted = Boolean(values['dotted']) + const duration = Number(values['duration']) as (1 | 2 | 4 | 8 | 16 | 32) + const tempo = Number(values['tempo']) + const rawDuration = convertToRawDuration(duration, tempo) + const formattedKeyData = formatKey({ + key: args[0].key, + dotted: isDotted, + duration, + }); + const newSongData = `${dataRef.current.value} ${formattedKeyData}`.trim() + dataRef.current.value = newSongData; + songRef.current = parseSong(newSongData, Number(values['tempo'])) + const oscillator = new WaveOscillator() + oscillator.start(args[0].key) + setTimeout(() => { + oscillator.stop() + }, rawDuration) + }; + + const addRest = () => { + if (!(formRef.current && dataRef.current)) { + return; + } + const values = getFormValues(formRef.current); + const formattedKeyData = formatKey({ + dotted: Boolean(values['dotted']), + duration: Number(values['duration']) as (1 | 2 | 4 | 8 | 16 | 32), + }); + const newSongData = `${dataRef.current.value} ${formattedKeyData}`.trim() + dataRef.current.value = newSongData; + songRef.current = parseSong(newSongData, Number(values['tempo'])) + }; + + const updateTempo = (e) => { + if (!isNaN(e.target.valueAsNumber) && e.target.valueAsNumber > 0) { + songRef.current = parseSong(dataRef.current.value, e.target.valueAsNumber) + } + } + + const togglePlayback = () => { + if (!formRef.current) { + return + } + setPlayTimestamp(Date.now()) + setPlaying(p => { + const newValue = !p + songRef.current.notes.forEach(n => { + if (!isNaN(n.key)) { + n.osc = n.osc || new WaveOscillator() + if (!newValue && !n.stopped) { + n.osc.stop() + } + } + n.played = newValue ? false : n.played + n.stopped = newValue ? false : n.stopped + }) + return newValue + }) + } + + const updateSong = () => { + const values = getFormValues(formRef.current); + songRef.current = parseSong(values['data'], Number(values['tempo'])) + } + + const updateView = (e) => { + const duration = Number(e.target.value); + setNoteGlyph(NOTE_GLYPHS[duration]); + setRestGlyph(REST_GLYPHS[duration]); + }; + + useEffect(() => { + let interval = setInterval(() => { + setCurrentTime(Date.now()) + }, 50) + + return () => { + clearInterval(interval) + } + }, []) + + useEffect(() => { + if (!playing) { + return + } + const cursor = currentTime - playTimestamp + if (cursor >= songRef.current.duration) { + setPlaying(false) + dataRef.current.setSelectionRange(null, null) + } + + songRef.current.notes.forEach((n) => { + const toPlay = !n.played && n.start < cursor + const toStop = !n.stopped && n.end < cursor + if (toPlay) { + if (!isNaN(n.key) && n.osc) { + n.osc.start(n.key) + } + dataRef.current.select() + dataRef.current.setSelectionRange(n.startIndex, n.endIndex) + n.played = true + } + + if (toStop) { + if (!isNaN(n.key) && n.osc) { + n.osc.stop() + } + n.stopped = true + } + }) + }, [currentTime, playTimestamp, playing]) + + useEffect(() => { + setHydrated(true); + }, []); + + // @ts-ignore return (
{ typeof (defaultValues.id as unknown) === 'string' @@ -51,12 +225,140 @@ const CreateRingtoneForm: FC = ({ block defaultValue={defaultValues.name} /> + + { + hydrated + && ( + + + + 𝅝 + + + 𝅗𝅥 + + + ♩ + + + ♪ + + + 𝅘𝅥𝅯 + + + 𝅘𝅥𝅰 + + + + {noteGlyph + ' + .'} + + + {restGlyph} + + + {playing ? '⏹' : '▶️'} + + + ) + }