diff --git a/package.json b/package.json index 38cc2ed..d51b426 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@types/node": "^16.11.6", "d3": "^7.1.1", "jacdac-ts": "^1.24.20", + "react-copy-to-clipboard": "^5.1.0", "react-jacdac": "^1.1.3", "rxjs": "^7.5.2", "rxjs-spy": "^8.0.2", diff --git a/src/index.jsx b/src/index.jsx index 53e8c1f..92186b1 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -17,6 +17,7 @@ ReactDOM.render( } /> } /> + , new SimpleDataHandler(), }, + { + name: 'Copy Handler', + id: `Copy Handler-${Math.floor(Math.random() * Date.now())}`, + description: 'Copies the data of the chosen sensor', + dataOutputs: [], + createHandler: (domain: [number, number]) => + new CopyToClipboardHandler([ + (domain[1] - domain[0]) * 0.4 + domain[0], + (domain[1] - domain[0]) * 0.6 + domain[0], + ]), + parameters: [ + { + name: 'Min', + type: 'number', + default: (obj?: DataHandler | DatumOutput) => { + if (obj) { + const frh = obj as CopyToClipboardHandler + return frh.domain[0] + } else { + return 0.4 + } + }, + handleUpdate: (value: number, obj?: DataHandler | DatumOutput) => { + if (obj) { + const frh = obj as CopyToClipboardHandler + frh.domain = [value, frh.domain[1]] + } + }, + }, + { + name: 'Max', + type: 'number', + default: (obj?: DataHandler | DatumOutput) => { + if (obj) { + const frh = obj as CopyToClipboardHandler + return frh.domain[1] + } else { + return 0.6 + } + }, + handleUpdate: (value: number, obj?: DataHandler | DatumOutput) => { + if (obj) { + const frh = obj as CopyToClipboardHandler + frh.domain = [frh.domain[0], value] + } + }, + }, + ], + }, ] function saveText(name: string, data: string, mimeType?: string) { @@ -450,18 +501,21 @@ export function DashboardView() { name: `${jds.specification.name} ${jds.device.name}`, id: serviceId, values: serviceInfo.values.map((v, i) => { - const sink = OutputEngine.getInstance().addSink( - `JacDac Service = ${jds.specification.name}; Index = ${i}`, - ) + const description = `JacDac Service = ${jds.specification.name}; Index = ${i}` + + const sink = OutputEngine.getInstance().addSink(description) const sinkId = sink.id const rawSubject = new Subject() + let sinkName = `${jds.specification.name} ${jds.device.name} ${v}` OutputEngine.getInstance().setStream(sinkId, rawSubject) const jdUnsubscribe = jds.readingRegister.subscribe(REPORT_UPDATE, () => { // console.log(jds.specification.name, v, jds.readingRegister.unpackedValue[i]) - rawSubject.next(new Datum(sinkId, jds.readingRegister.unpackedValue[i])) + let datum: Datum = new Datum(sinkId, jds.readingRegister.unpackedValue[i]) + datum.setSinkName(sinkName) + rawSubject.next(datum) }) const unsubscribe = () => { @@ -546,6 +600,7 @@ export function DashboardView() { template: DataHandlerWrapper, ) => { console.log(add, serviceId, valueId, template) + const servicesCopy = services.map((service) => { if (serviceId === service.id) { const values = service.values.map((value) => { @@ -730,7 +785,6 @@ export function DashboardView() { type: 'number', }} /> */} - diff --git a/src/pages/Demo.tsx b/src/pages/Demo.tsx index 31ad9b7..a45a448 100644 --- a/src/pages/Demo.tsx +++ b/src/pages/Demo.tsx @@ -24,11 +24,15 @@ import Table from 'arquero/dist/types/table/table' const DEMO_VIEW_MAP = { simple: { value: 'simple', label: 'Simple sonification', component: DemoSimple }, highlightRegion: { value: 'highlightRegion', label: 'Highlight points for region', component: DemoHighlightRegion }, - speechHighlight: { value: 'speechHighlight', label: 'Speak points in range', component: DemoSpeakRange}, - fileOutput: {value: 'fileOutput', label: 'Point of interest notification', component: DemoFileOutput}, - slopeParityV1: {value: 'slopeParityV1', label: 'Slope direction change notification', component: DemoSlopeParityV1}, - slopeParityV2: {value: 'slopeParityV2', label: 'Slope parity notification', component: DemoSlopeParityV2}, - runningExtrema: {value: 'runningExtrema', label: 'Running extrema notification', component: DemoRunningExtrema} + speechHighlight: { value: 'speechHighlight', label: 'Speak points in range', component: DemoSpeakRange }, + fileOutput: { value: 'fileOutput', label: 'Point of interest notification', component: DemoFileOutput }, + slopeParityV1: { + value: 'slopeParityV1', + label: 'Slope direction change notification', + component: DemoSlopeParityV1, + }, + slopeParityV2: { value: 'slopeParityV2', label: 'Slope parity notification', component: DemoSlopeParityV2 }, + runningExtrema: { value: 'runningExtrema', label: 'Running extrema notification', component: DemoRunningExtrema }, } let demoViewRef: React.RefObject | DemoHighlightRegion> = React.createRef() diff --git a/src/pages/MicrobitController.tsx b/src/pages/MicrobitController.tsx index 6007812..dd5c9f5 100644 --- a/src/pages/MicrobitController.tsx +++ b/src/pages/MicrobitController.tsx @@ -42,7 +42,7 @@ function filterNullish(): UnaryFunction, Obs return pipe(filter((x) => x != null) as OperatorFunction) } -function MicroBitButton(props: {outputEngine: OutputEngine, service: JDService}) { +function MicroBitButton(props: { outputEngine: OutputEngine; service: JDService }) { const { service, outputEngine } = props const downEvent = useEvent(service, ButtonEvent.Down) const upEvent = useEvent(service, ButtonEvent.Up) @@ -54,26 +54,22 @@ function MicroBitButton(props: {outputEngine: OutputEngine, service: JDService}) const handleButton = (id: string, down: boolean) => { // only act on an up event - if (down) - setState("down") - else - setState("up") + if (down) setState('down') + else setState('up') if (down) return - if (id === "A+B") { - if (outputEngine.value === OutputStateChange.Play) - outputEngine.next(OutputStateChange.Stop) - else - outputEngine.next(OutputStateChange.Play) + if (id === 'A+B') { + if (outputEngine.value === OutputStateChange.Play) outputEngine.next(OutputStateChange.Stop) + else outputEngine.next(OutputStateChange.Play) } } useEffect(() => { - if (instanceName === "") return + if (instanceName === '') return downEvent.subscribe(EVENT, () => handleButton(instanceName, true)) }, [downEvent, instanceName]) - useEffect(() =>{ - if (instanceName === "") return + useEffect(() => { + if (instanceName === '') return upEvent.subscribe(EVENT, () => handleButton(instanceName, false)) }, [upEvent, instanceName]) @@ -81,7 +77,14 @@ function MicroBitButton(props: {outputEngine: OutputEngine, service: JDService}) const resolveIName = async () => setInstanceName(await service.resolveInstanceName()) resolveIName() }, []) - return (

<>{instanceName} {state}
) + return ( +
+
+ <> + {instanceName} {state} + +
+ ) } function ConnectButton() { @@ -123,13 +126,11 @@ function ConnectButton() { return () => unsubs?.() }, [services]) - useEffect(()=>{ - OutputEngine.getInstance().subscribe((e: OutputStateChange)=>{ - console.log("Chaning streaming state ", e) - if (e === OutputStateChange.Play) - setStreaming(true); - else - setStreaming(false); + useEffect(() => { + OutputEngine.getInstance().subscribe((e: OutputStateChange) => { + console.log('Chaning streaming state ', e) + if (e === OutputStateChange.Play) setStreaming(true) + else setStreaming(false) }) return () => OutputEngine.getInstance().unsubscribe() }, []) @@ -234,7 +235,7 @@ function ConnectButton() { } } - console.log("STREAMING STATE: ", streaming) + console.log('STREAMING STATE: ', streaming) return ( <> @@ -245,7 +246,9 @@ function ConnectButton() { )} {buttons && - buttons.map((button,i) => )} + buttons.map((button, i) => ( + + ))} ) } diff --git a/src/sonification/CopyEngine.ts b/src/sonification/CopyEngine.ts new file mode 100644 index 0000000..5dc1e7a --- /dev/null +++ b/src/sonification/CopyEngine.ts @@ -0,0 +1,49 @@ +import { Datum } from './Datum' +import { getSonificationLoggingLevel, OutputStateChange, SonificationLoggingLevel } from './OutputConstants' +import { DataSink } from './DataSink' +import { BehaviorSubject, distinctUntilChanged, map, merge, Observable, shareReplay, tap } from 'rxjs' +import assert from 'assert' +import { create } from 'rxjs-spy' + +const DEBUG = false + +export class CopyEngine { + /** + * The Output Engine. Enforce that there is only ever one. + * @todo ask group if there is a better way to enforce this. + */ + private static copyEngineInstance: CopyEngine + private copiedDataMap: Map = new Map() + + public setCopiedData(key: String | undefined, data: Datum[]) { + if (key !== undefined) { + if (this.copiedDataMap.has(key)) { + const existingArray = this.copiedDataMap.get(key) + if (existingArray) { + existingArray.push(...data) + } + } else { + this.copiedDataMap.set(key, data) + } + } else { + // Handle the case where SinkName is undefined + console.error('getSinkName() returned undefined') + } + } + + public printCopiedData(): void { + console.log(this.copiedDataMap) + } + + /** + * Create a new CopyEngine. Enforces that there is only ever one + * @returns The CopyEngine's instance.. + */ + public static getInstance(): CopyEngine { + if (!CopyEngine.copyEngineInstance) { + CopyEngine.copyEngineInstance = new CopyEngine() + } + + return CopyEngine.copyEngineInstance + } +} diff --git a/src/sonification/Datum.ts b/src/sonification/Datum.ts index 47d75c7..e538de6 100644 --- a/src/sonification/Datum.ts +++ b/src/sonification/Datum.ts @@ -6,8 +6,6 @@ import * as d3 from 'd3' * * All data points have certain properties and abilities, however * @field value The raw data value associated with this point - * @field adjustedValue An adjusted value that may be assigned to a point for output. - * @field previous The previous point in the sequence for this sink * @method toString() Returns a string describing this data point * @field sink The data sink this point is associated with [not sure if we need this pointer, but for completeness...] */ @@ -16,6 +14,7 @@ export class Datum { value: number sinkId: number time: number + sinkName?: String constructor(sinkId: number, value: number, time?: number) { this.value = value @@ -24,6 +23,14 @@ export class Datum { else this.time = d3.now() } + public setSinkName(sinkName: String) { + this.sinkName = sinkName + } + + public getSinkName() { + return this.sinkName + } + public toString(): string { return `(raw: ${this.value}, ${this.time})` } diff --git a/src/sonification/handler/CopyToClipboardHandler.ts b/src/sonification/handler/CopyToClipboardHandler.ts new file mode 100644 index 0000000..2b72b21 --- /dev/null +++ b/src/sonification/handler/CopyToClipboardHandler.ts @@ -0,0 +1,132 @@ +import { DataHandler } from './DataHandler' +import { DataSink } from '../DataSink' +import { Datum } from '../Datum' +import { DatumOutput } from '../output/DatumOutput' +import { Observable, filter } from 'rxjs' +import { getSonificationLoggingLevel, OutputStateChange, SonificationLoggingLevel } from '../OutputConstants' +import { tap } from 'rxjs/operators' +import { CopyEngine } from '../CopyEngine' + +const DEBUG = false + +export class CopyToClipboardHandler extends DataHandler { + private copyEngine: CopyEngine = CopyEngine.getInstance() + + private copiedData: Datum[] = [] + + private _domain: [number, number] + public get domain(): [number, number] { + return this._domain + } + public set domain(value: [number, number]) { + this._domain = value + } + + public insideDomain(num: number): boolean { + debugStatic(SonificationLoggingLevel.DEBUG, `checking if ${num} is inside ${this.domain}`) + return num >= this.domain[0] && num <= this.domain[1] + } + + constructor(domain?: [number, number], output?: DatumOutput) { + super(output) + debugStatic(SonificationLoggingLevel.DEBUG, 'setting up Copy handler') + if (domain) { + debugStatic(SonificationLoggingLevel.DEBUG, `setting up copy range handeler with domain ${domain}`) + this._domain = domain + } else this._domain = [0, 0] + } + + /** + * Set up a subscription to copy the incoming data + * + * @param sink$ The sink that is producing data for us + */ + public setupSubscription(sink$: Observable) { + super.setupSubscription( + sink$.pipe( + tap((data) => { + if (data instanceof Datum) { + if (this.insideDomain(data.value)) this.copiedData.push(data) + } + }), + ), + ) + } + + /** + * Get the copied data + * + * @returns The copied data + */ + public getCopiedData(): Datum[] { + return this.copiedData + } + + /** + * Copies the data in the copiedData array as CSV to the clipboard + */ + public copyToClipboard() { + if (this.copiedData.length === 0) { + console.log('No data available to copy') + return + } + + const headings = Object.keys(this.copiedData[0]) + const key: String | undefined = this.copiedData[0].getSinkName() + + this.copyEngine.setCopiedData(key, this.copiedData) + + const csvData = [headings.join(',')] + .concat(this.copiedData.map((datum) => Object.values(datum).join(','))) + .join('\n') + + navigator.clipboard.writeText(csvData).then(() => { + console.log('Data copied to clipboard') + }) + } + + /** + * Clear the copied data + */ + public clearCopiedData() { + this.copiedData = [] + } + + complete(): void { + this.copyEngine.printCopiedData() + this.copyToClipboard() + this.clearCopiedData() + super.complete() + } + + public toString(): string { + return `CopyRangeHandler: Keeping only data in ${this.domain[0]},${this.domain[1]}` + } +} + +//////////// DEBUGGING ////////////////// +import { tag } from 'rxjs-spy/operators/tag' +const debug = (level: number, message: string, watch: boolean) => (source: Observable) => { + if (watch) { + return source.pipe( + tap((val) => { + debugStatic(level, message + ': ' + val) + }), + tag(message), + ) + } else { + return source.pipe( + tap((val) => { + debugStatic(level, message + ': ' + val) + }), + ) + } +} + +const debugStatic = (level: number, message: string) => { + if (DEBUG) { + if (level >= getSonificationLoggingLevel()) { + console.log(message) + } // else console.log('debug message dumped') + } +} diff --git a/src/sonification/handler/DataHandler.ts b/src/sonification/handler/DataHandler.ts index 0ba1ba7..5665648 100644 --- a/src/sonification/handler/DataHandler.ts +++ b/src/sonification/handler/DataHandler.ts @@ -8,7 +8,6 @@ const DEBUG = false * A DataHandler class is used to decide how to output each data point. */ export abstract class DataHandler extends Subject { - private subscription?: Subscription private _outputs: Array @@ -23,8 +22,8 @@ export abstract class DataHandler extends Subject { } /** - * - * @param output + * + * @param output */ public removeOutput(output: DatumOutput) { this._outputs = this._outputs.filter((o) => o !== output) @@ -70,7 +69,9 @@ export abstract class DataHandler extends Subject { */ constructor(output?: DatumOutput) { super() + this._outputs = new Array() + if (output) this.addOutput(output) } } diff --git a/src/sonification/handler/ScaleHandler.ts b/src/sonification/handler/ScaleHandler.ts index 41c2aa4..900ff28 100644 --- a/src/sonification/handler/ScaleHandler.ts +++ b/src/sonification/handler/ScaleHandler.ts @@ -12,11 +12,8 @@ import { import { RangeEndExpander } from '../stat/RangeEndExpander' import assert from 'assert' - const DEBUG = false - - /** * A DataHandler that scales the given value based on a specified min and max * @@ -118,7 +115,6 @@ export class ScaleHandler extends DataHandler { }), debug(SonificationLoggingLevel.DEBUG, 'scaled', DEBUG), - ), ) } @@ -148,11 +144,9 @@ const debug = (level: number, message: string, watch: boolean) => (source: Obser } const debugStatic = (level: number, message: string) => { - if (DEBUG) { if (level >= getSonificationLoggingLevel()) { console.log(message) } //else console.log('debug message dumped') } - } diff --git a/src/sonification/handler/SimpleDataHandler.ts b/src/sonification/handler/SimpleDataHandler.ts index 23fecc5..115b2c5 100644 --- a/src/sonification/handler/SimpleDataHandler.ts +++ b/src/sonification/handler/SimpleDataHandler.ts @@ -3,13 +3,18 @@ import { Datum } from '../Datum' import { DatumOutput } from '../output/DatumOutput' import { DataHandler } from './DataHandler' import { bufferCount, filter, map, Observable, tap } from 'rxjs' -import { getSonificationLoggingLevel, OutputStateChange, SonificationLevel, SonificationLoggingLevel } from '../OutputConstants' +import { + getSonificationLoggingLevel, + OutputStateChange, + SonificationLevel, + SonificationLoggingLevel, +} from '../OutputConstants' const DEBUG = true /** * A DataHandler that notifies if a set of point/s are seen - */ + */ export class SimpleDataHandler extends DataHandler { /** * a value denoting the number of points that the user should be notified after. defaults to 1 if not specified in the constructor. The user is then notified for every point. @@ -41,15 +46,14 @@ export class SimpleDataHandler extends DataHandler { * * @param sink The sink that is producing data for us */ - public setupSubscription(sink$: Observable) { + public setupSubscription(sink$: Observable) { super.setupSubscription( - sink$.pipe(bufferCount(this.threshold), -map((frames) => { - return frames[frames.length-1] -} - - ), - filter((val) => { + sink$.pipe( + bufferCount(this.threshold), + map((frames) => { + return frames[frames.length - 1] + }), + filter((val) => { return true }), ), @@ -90,4 +94,4 @@ const debugStatic = (level: number, message: string) => { console.log(message) } else console.log('debug message dumped') } -} \ No newline at end of file +} diff --git a/src/sonification/output/Copy.ts b/src/sonification/output/Copy.ts new file mode 100644 index 0000000..b6435f7 --- /dev/null +++ b/src/sonification/output/Copy.ts @@ -0,0 +1,33 @@ +import { Datum } from '../Datum' +import { getSonificationLoggingLevel, OutputStateChange, SonificationLoggingLevel } from '../OutputConstants' +import { DatumOutput } from './DatumOutput' +import { CopyToClipboardHandler } from '../handler/CopyToClipboardHandler' + +const DEBUG = true + +// Define the CopyToClipboardOutput class +export class Copy extends DatumOutput { + private copyToClipboardHandler?: CopyToClipboardHandler + + constructor(copyToClipboardHandler: CopyToClipboardHandler) { + super() + this.copyToClipboardHandler = copyToClipboardHandler + } + + public copyToClipboard(data: Datum) { + // Implement the copy to clipboard functionality using the provided data + } + + public removeFromClipboard() { + // Implement the remove from clipboard functionality + } + + protected output(datum: Datum) { + super.output(datum) + this.copyToClipboard(datum) // Call the copyToClipboard method when outputting the datum + } + + public toString(): string { + return 'Copy' + } +} diff --git a/yarn.lock b/yarn.lock index 0223fc7..8a5d7e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4395,9 +4395,9 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001272, caniuse-lite@^1.0.30001274: - version "1.0.30001276" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001276.tgz#7049685eb972eb09c0ecbb57227b489d76244fb1" - integrity sha512-psUNoaG1ilknZPxi8HuhQWobuhLqtYSRUxplfVkEJdgZNB9TETVYGSBtv4YyfAdGvE6gn2eb0ztiXqHoWJcGnw== + version "1.0.30001489" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001489.tgz" + integrity sha512-x1mgZEXK8jHIfAxm+xgdpHpk50IN3z3q3zP261/WS+uvePxW8izXuCu6AHz0lkuYTlATDehiZ/tNyYBdSQsOUQ== capture-exit@^2.0.0: version "2.0.0" @@ -5007,6 +5007,13 @@ copy-descriptor@^0.1.0: resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= +copy-to-clipboard@^3.3.1: + version "3.3.3" + resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz#55ac43a1db8ae639a4bd99511c148cdd1b83a1b0" + integrity sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA== + dependencies: + toggle-selection "^1.0.6" + core-js-compat@^3.18.0, core-js-compat@^3.19.0, core-js-compat@^3.6.2: version "3.19.1" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.19.1.tgz#fe598f1a9bf37310d77c3813968e9f7c7bb99476" @@ -12748,6 +12755,15 @@ prop-types@^15.6.2, prop-types@^15.7.2: object-assign "^4.1.1" react-is "^16.8.1" +prop-types@^15.8.1: + version "15.8.1" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" + integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.13.1" + proxy-addr@~2.0.5: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -12940,6 +12956,14 @@ react-app-polyfill@^2.0.0: regenerator-runtime "^0.13.7" whatwg-fetch "^3.4.1" +react-copy-to-clipboard@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.0.tgz#09aae5ec4c62750ccb2e6421a58725eabc41255c" + integrity sha512-k61RsNgAayIJNoy9yDsYzDe/yAZAzEbEgcz3DZMhF686LEyukcE1hzurxe85JandPUG+yTfGVFzuEw3xt8WP/A== + dependencies: + copy-to-clipboard "^3.3.1" + prop-types "^15.8.1" + react-dev-utils@^11.0.3: version "11.0.4" resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-11.0.4.tgz#a7ccb60257a1ca2e0efe7a83e38e6700d17aa37a" @@ -12984,7 +13008,7 @@ react-error-overlay@^6.0.9: resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a" integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew== -react-is@^16.7.0, react-is@^16.8.1: +react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -15018,6 +15042,11 @@ to-regex@^3.0.1, to-regex@^3.0.2: regex-not "^1.0.2" safe-regex "^1.1.0" +toggle-selection@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32" + integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ== + toidentifier@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"