This commit is contained in:
Michael "Z" Goddard 2022-02-23 17:56:43 -05:00
Родитель d2f6ac0bd0
Коммит fae1062b00
28 изменённых файлов: 1311 добавлений и 503 удалений

64
package-lock.json сгенерированный
Просмотреть файл

@ -2648,18 +2648,18 @@
}
},
"css-loader": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.2.0.tgz",
"integrity": "sha512-/rvHfYRjIpymZblf49w8jYcRo2y9gj6rV8UroHGmBxKrIyGLokpycyKzp9OkitvqT29ZSpzJ0Ic7SpnJX3sC8g==",
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.6.0.tgz",
"integrity": "sha512-FK7H2lisOixPT406s5gZM1S3l8GrfhEBT3ZiL2UX1Ng1XWs0y2GPllz/OTyvbaHe12VgQrIXIzuEGVlbUhodqg==",
"dev": true,
"requires": {
"icss-utils": "^5.1.0",
"postcss": "^8.2.15",
"postcss": "^8.4.5",
"postcss-modules-extract-imports": "^3.0.0",
"postcss-modules-local-by-default": "^4.0.0",
"postcss-modules-scope": "^3.0.0",
"postcss-modules-values": "^4.0.0",
"postcss-value-parser": "^4.1.0",
"postcss-value-parser": "^4.2.0",
"semver": "^7.3.5"
}
},
@ -5120,9 +5120,9 @@
"dev": true
},
"nanoid": {
"version": "3.1.25",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.25.tgz",
"integrity": "sha512-rdwtIXaXCLFAQbnfqDRnI6jaRHp9fTcYBjtFKE8eezcZ7LuLjhUaQGNeMXf1HmRoCH32CLz6XwX0TtxEOS/A3Q==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz",
"integrity": "sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==",
"dev": true
},
"natural-compare": {
@ -5356,6 +5356,12 @@
"integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=",
"dev": true
},
"picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
"dev": true
},
"picomatch": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz",
@ -5441,14 +5447,22 @@
}
},
"postcss": {
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.3.6.tgz",
"integrity": "sha512-wG1cc/JhRgdqB6WHEuyLTedf3KIRuD0hG6ldkFEZNCjRxiC+3i6kkWUUbiJQayP28iwG35cEmAbe98585BYV0A==",
"version": "8.4.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.6.tgz",
"integrity": "sha512-OovjwIzs9Te46vlEx7+uXB0PLijpwjXGKXjVGGPIGubGpq7uh5Xgf6D6FiJ/SzJMBosHDp6a2hiXOS97iBXcaA==",
"dev": true,
"requires": {
"colorette": "^1.2.2",
"nanoid": "^3.1.23",
"source-map-js": "^0.6.2"
"nanoid": "^3.2.0",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"dependencies": {
"source-map-js": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
"dev": true
}
}
},
"postcss-modules-extract-imports": {
@ -5487,9 +5501,9 @@
}
},
"postcss-selector-parser": {
"version": "6.0.6",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.6.tgz",
"integrity": "sha512-9LXrvaaX3+mcv5xkg5kFwqSzSH1JIObIx51PrndZwlmznwXRfxMddDvo9gve3gVR8ZTKgoFDdWkbRFmEhT4PMg==",
"version": "6.0.9",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.9.tgz",
"integrity": "sha512-UO3SgnZOVTwu4kyLR22UQ1xZh086RyNZppb7lLAKBFK8a32ttG5i87Y/P3+2bRSjZNyJ1B7hfFNo273tKe9YxQ==",
"dev": true,
"requires": {
"cssesc": "^3.0.0",
@ -5497,9 +5511,9 @@
}
},
"postcss-value-parser": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz",
"integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true
},
"prelude-ls": {
@ -5624,6 +5638,16 @@
"safe-buffer": "^5.1.0"
}
},
"raw-loader": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz",
"integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==",
"dev": true,
"requires": {
"loader-utils": "^2.0.0",
"schema-utils": "^3.0.0"
}
},
"react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",

Просмотреть файл

@ -34,7 +34,7 @@
"@types/dagre": "^0.7.46",
"@types/graphlib": "^2.1.8",
"babel-jest": "^27.0.6",
"css-loader": "^6.2.0",
"css-loader": "^6.6.0",
"devtools-protocol": "^0.0.924232",
"eslint": "^7.30.0",
"eslint-config-google": "^0.14.0",
@ -48,6 +48,7 @@
"pinst": ">=2",
"prettier": "^2.3.2",
"puppeteer": "^9.1.1",
"raw-loader": "^4.0.2",
"rimraf": "^3.0.2",
"source-map-loader": "^3.0.0",
"style-loader": "^3.2.1",

Просмотреть файл

@ -33,6 +33,7 @@
*
* @typedef Chrome.DevToolsPanels
* @property {Chrome.DevToolsPanelsCreateFunction} create
* @property {'default' | 'dark'} themeName
*/
/**

Просмотреть файл

@ -0,0 +1,466 @@
import {
BehaviorSubject,
combineLatest,
concat,
defer,
Observable,
of,
} from 'rxjs';
import {
catchError,
distinctUntilChanged,
exhaustMap,
filter,
finalize,
share,
take,
} from 'rxjs/operators';
import {chrome} from '../chrome';
import {WebAudioDebuggerMethod} from '../chrome/DebuggerWebAudioDomain';
/**
* Permission value in regards to calling `chrome.debugger.attach`.
*
* When the extension calls `chrome.debugger.attach` a notification will display
* in devtools that the extension is debugging the tab. Attaching when the user
* does not expect it and then see this notification is not desired. The user
* needs to grant permission for the extension the privilege to attach, or
* reject prior permission.
*
* Permission could be implied when the extension's panel is opened.
*
* Permission should be rejected when the debugging notification is canceled or
* dismissed.
*
* Permission could be granted more explicitly by a panel component when the
* panel is visible but the extension does not have permission.
*
* WebAudioEventObserver will be instructed with rules like the above by other
* functions outside of this file.
*/
enum AttachPermission {
/**
* Initial value.
*
* When WebAudioEventObserver is created, it does not know if permission has
* been granted or not and should treat this as **not having** permission.
*/
UNKNOWN,
/**
* Permission has been granted by a user action. WebAudioEventObserver may
* attach to `chrome.debugger`.
*/
TEMPORARY,
/**
* Permission has been rejected. WebAudioEventObserver must not attach to
* `chrome.debugger`.
*/
REJECTED,
}
/**
* Value used to indicate if the `chrome.debugger` attachment and
* receiving `chrome.debugger.onEvent` events are "active".
*/
enum BinaryTransition {
DEACTIVATING = 'deactivating',
IS_INACTIVE = 'isInactive',
ACTIVATING = 'activating',
IS_ACTIVE = 'isActive',
}
export interface DebuggerAttachEventState {
permission: AttachPermission;
attachInterest: number;
attachState: BinaryTransition;
webAudioEventInterest: number;
webAudioEventState: BinaryTransition;
}
/** Chrome Devtools Protocol version to attach to. */
const debuggerVersion = '1.3';
/** Chrome tab to attach the debugger to. */
const {tabId} = chrome.devtools.inspectedWindow;
/**
* Control attachment to chrome.debugger depending on if the user has given
* permission and how many parts of the extension need attachment.
*
* @memberof Audion
* @alias DebuggerAttachEventController
*/
export class DebuggerAttachEventController {
/** Does user permit extension to use `chrome.debugger`. */
permission$: PermissionSubject;
/** How many subscriptions want to attach to `chrome.debugger`. */
attachInterest$: CounterSubject;
attachState$: Observable<BinaryTransition>;
/**
* How many subscriptions want to receive web audio events through
* `chrome.debugger.onEvent`.
*/
webAudioEventInterest$: CounterSubject;
webAudioEventState$: Observable<BinaryTransition>;
combinedState$: Observable<DebuggerAttachEventState>;
constructor() {
// Create an interface of subjects to track changes in state with the
// `chrome.debugger` api.
const debuggerSubject = {
// Does the extension have permission from the user to use `chrome.debugger` api.
permission: new PermissionSubject(),
// How many entities want to attach to the debugger to call `sendCommand`
// or listen to `onEvent`.
attachInterest: new CounterSubject(0),
// attachState must be IS_ACTIVE for `chrome.debugger.sendCommand` to be used.
attachState: new BinaryTransitionSubject({
initialState: BinaryTransition.IS_INACTIVE,
activateAction: () => attach({tabId}, debuggerVersion),
deactivateAction: () => detach({tabId}),
}),
// How many entities want to listen to web audio events through `onEvent`.
webAudioEventInterest: new CounterSubject(0),
// webAudioEventState must be IS_ACTIVE for `onEvent` to receive events.
webAudioEventState: new BinaryTransitionSubject({
initialState: BinaryTransition.IS_INACTIVE,
activateAction: () =>
sendCommand({tabId}, WebAudioDebuggerMethod.enable),
deactivateAction: () =>
sendCommand({tabId}, WebAudioDebuggerMethod.disable),
}),
};
this.permission$ = debuggerSubject.permission;
this.attachInterest$ = debuggerSubject.attachInterest;
this.attachState$ = debuggerSubject.attachState;
this.webAudioEventInterest$ = debuggerSubject.webAudioEventInterest;
this.webAudioEventState$ = debuggerSubject.webAudioEventState;
// Observable of changes to state derived from debuggerSubject.
const debuggerState$ = (this.combinedState$ =
// Push objects mapping of keys in debuggerSubject to values pushed from
// that debuggerSubject member.
combineLatest(debuggerSubject).pipe(
// Filter out combined state that is not different from the last value.
distinctUntilChanged(
(previous, current) =>
previous.permission === current.permission &&
previous.attachInterest === current.attachInterest &&
previous.attachState === current.attachState &&
previous.webAudioEventInterest === current.webAudioEventInterest &&
previous.webAudioEventState === current.webAudioEventState,
),
// Make one subscription debuggerSubject once for many subscribers.
share(),
));
// The following subscriptions govern debuggerSubject.
// Govern attachment to `chrome.debugger`.
debuggerState$.subscribe({
next: (state) => {
// When debugger state has permission to attach to `chrome.debugger` and
// something wants to use `chrome.debugger`, activate the attachment.
// Otherwise deactivate the attachment.
if (
state.permission === AttachPermission.TEMPORARY &&
state.attachInterest > 0
) {
debuggerSubject.attachState.activate();
} else {
debuggerSubject.attachState.deactivate();
}
},
});
// Govern permission rejection and externally induced detachment.
onDebuggerDetach$.subscribe({
next([, reason]) {
if (reason === 'canceled_by_user') {
// Reject permission to use `chrome.debugger` in this extension. We
// understand this event to be an explicit rejection from the
// extension's user.
debuggerSubject.permission.reject();
}
// Immediately go to the inactive state. Detachment was initiated
// outside the extension and does not need to be requested.
debuggerSubject.attachState.next(BinaryTransition.IS_INACTIVE);
},
});
// Govern receiving web audio events through `chrome.debugger.onEvent`.
debuggerState$.subscribe({
next(state) {
if (
state.attachState === BinaryTransition.IS_ACTIVE &&
state.webAudioEventInterest > 0
) {
// Start receiving events. The attachemnt is active and some entities
// are listeneing for events.
debuggerSubject.webAudioEventState.activate();
} else {
if (state.attachState === BinaryTransition.IS_ACTIVE) {
// Stop receiving events. The attachment is still active but no
// entities are listening for events.
debuggerSubject.webAudioEventState.deactivate();
} else {
// "Skip" deactivation of receiving events and immediately go to
// the inactive state. The process of detachment either requested by
// the extension or initiated otherwise has implicitly stopped
// reception of events.
debuggerSubject.webAudioEventState.next(
BinaryTransition.IS_INACTIVE,
);
}
}
},
});
}
/**
* Attach to the debugger if not already, and call chrome.debugger.sendCommand.
* @param method Chrome devtools protocol method like 'HeapProfiler.collectGarbage'.
* @returns observable that completes once done without pushing any values
*/
sendCommand(method: string): Observable<never> {
this.attachInterest$.increment();
return this.attachState$.pipe(
filter((state) => state === BinaryTransition.IS_ACTIVE),
take(1),
exhaustMap(() => sendCommand({tabId}, method)),
finalize(() => this.attachInterest$.decrement()),
);
}
}
/**
* Create a function that returns an observable that completes when the api
* calls back.
* @param method `chrome` api method whose last argument is a callback
* @param thisArg `this` inside of the method
* @returns observable that completes when the method is done
*/
function bindChromeCallback<P extends any[]>(
method: (...args: [...params: P, callback: () => void]) => void,
thisArg = null,
) {
return (...args: P) =>
new Observable<never>((subscriber) => {
method.call(thisArg, ...args, () => {
if (chrome.runtime.lastError) {
subscriber.error(chrome.runtime.lastError);
} else {
subscriber.complete();
}
});
});
}
/**
* Return an observable that pushes events from a `chrome` api event.
* @param event `chrome` api event
* @returns observable of `chrome` api events
*/
function fromChromeEvent<A extends any[]>(
event: Chrome.Event<(...args: A) => any>,
) {
return new Observable<A>((subscriber) => {
const listener = (...args: A) => {
subscriber.next(args);
};
event.addListener(listener);
return () => {
event.removeListener(listener);
};
});
}
/**
* Call `chrome.debugger.attach`.
*
* @see
* https://developer.chrome.com/docs/extensions/reference/debugger/#method-attach
*/
const attach = bindChromeCallback(chrome.debugger.attach, chrome.debugger);
/**
* Call `chrome.debugger.detach`.
*
* @see
* https://developer.chrome.com/docs/extensions/reference/debugger/#method-detach
*/
const detach = bindChromeCallback(chrome.debugger.detach, chrome.debugger);
/**
* Call `chrome.debugger.sendCommand`.
*
* @see
* https://developer.chrome.com/docs/extensions/reference/debugger/#method-sendCommand
*/
const sendCommand = bindChromeCallback(
chrome.debugger.sendCommand as (
target: Chrome.DebuggerDebuggee,
method: string,
params?,
callback?,
) => void,
chrome.debugger,
);
/**
* Observable of `chrome.debugger.onDetach` events.
*/
const onDebuggerDetach$ = fromChromeEvent<
[target: Chrome.DebuggerDebuggee, reason: string]
>(chrome.debugger.onDetach);
/**
* Store if user allows the extension to use `chrome.debugger` api.
*/
export class PermissionSubject extends BehaviorSubject<AttachPermission> {
constructor() {
super(AttachPermission.UNKNOWN);
}
/**
* Permit use of `chrome.debugger`.
*/
grantTemporary() {
if (this.value === AttachPermission.UNKNOWN) {
this.next(AttachPermission.TEMPORARY);
}
}
/**
* Reject use of `chrome.debugger`.
*/
reject() {
if (this.value !== AttachPermission.REJECTED) {
this.next(AttachPermission.REJECTED);
}
}
}
/**
* Description of a transition in BinaryTransitionSubject.
*/
interface BinaryTransitionDescription {
/** The state the Subject must start in to perform this transition. */
beginningState: BinaryTransition;
/** The state the Subject is in while performing this transition. */
intermediateState: BinaryTransition;
/** The state the Subject is in after action is successfully. */
successState: BinaryTransition;
/** The state the Subject is in after action is unsuccessful. */
errorState: BinaryTransition;
/**
* Delegate that does some work to modify other application state to the
* desired state.
*/
action: () => Observable<void>;
}
/**
* Control a transition between inactive and active state. To perform a
* transition the subject enters a intermediate state and calls a delegate to do
* some action. After the action completes successfully the subject enters the
* desired state.
*/
class BinaryTransitionSubject extends BehaviorSubject<BinaryTransition> {
private readonly activateTransition: BinaryTransitionDescription;
private readonly deactivateTransition: BinaryTransitionDescription;
constructor({
initialState,
activateAction,
deactivateAction,
}: {
initialState: BinaryTransition;
activateAction: () => Observable<void>;
deactivateAction: () => Observable<void>;
}) {
super(initialState);
this.activateTransition = {
beginningState: BinaryTransition.IS_INACTIVE,
intermediateState: BinaryTransition.ACTIVATING,
successState: BinaryTransition.IS_ACTIVE,
errorState: BinaryTransition.IS_INACTIVE,
action: activateAction,
};
this.deactivateTransition = {
beginningState: BinaryTransition.IS_ACTIVE,
intermediateState: BinaryTransition.DEACTIVATING,
successState: BinaryTransition.IS_INACTIVE,
errorState: BinaryTransition.IS_INACTIVE,
action: deactivateAction,
};
}
/**
* Transition to a desired state.
*
* Change the subject value if it is set to beginningState to intermediateState and once action completes successfuly, set to successState.
* @param description
*/
transition(description: BinaryTransitionDescription) {
if (this.value === description.beginningState) {
concat(
of(description.intermediateState),
description.action(),
defer(() =>
this.value === description.intermediateState
? of(description.successState)
: of(),
),
)
.pipe(
catchError((err) =>
of(
this.value === description.intermediateState
? description.errorState
: description.beginningState,
),
),
)
.subscribe({next: this.next.bind(this)});
}
}
/**
* If subject is inactive, transition to active.
*/
activate() {
this.transition(this.activateTransition);
}
/**
* If subject is active, transition to inactive.
*/
deactivate() {
this.transition(this.deactivateTransition);
}
}
/**
* Observable counting some discrete value.
*/
export class CounterSubject extends BehaviorSubject<number> {
/**
* Increase value by 1.
*/
increment() {
this.next(this.value + 1);
}
/**
* Decrease value by 1.
*/
decrement() {
this.next(this.value - 1);
}
}

Просмотреть файл

@ -1,6 +1,7 @@
/// <reference path="../utils/Types.ts" />
/// <reference path="Types.ts" />
import {Subject} from 'rxjs';
import {chrome} from '../chrome';
/**
@ -17,6 +18,9 @@ export class DevtoolsGraphPanel {
constructor(devtoolsObserver) {
this.onShow = null;
/** @type {Subject<Audion.DevtoolsRequest>} */
this.requests$ = new Subject();
chrome.devtools.panels.create('Web Audio', '', 'panel.html', (panel) => {
panel.onShown.addListener(() => {
if (this.onShow) {
@ -26,6 +30,8 @@ export class DevtoolsGraphPanel {
});
chrome.runtime.onConnect.addListener((port) => {
port.onMessage.addListener((value) => this.requests$.next(value));
const unsubscribe = devtoolsObserver.observe((graph) => {
port.postMessage(graph);
});

Просмотреть файл

@ -43,6 +43,12 @@ export namespace Audion {
export type DevtoolsMessage = GraphContextMessage | AllGraphsMessage;
export interface DevtoolsCollectGarbageRequest {
type: 'collectGarbage';
}
export type DevtoolsRequest = DevtoolsCollectGarbageRequest;
export interface DevtoolsObserver extends Utils.Observer<DevtoolsMessage> {}
export interface GraphNode {

Просмотреть файл

@ -1,82 +1,19 @@
import {
BehaviorSubject,
combineLatest,
concat,
defer,
Observable,
of,
} from 'rxjs';
import {catchError, distinctUntilChanged, share} from 'rxjs/operators';
import {chrome} from '../chrome';
import {WebAudioDebuggerMethod} from '../chrome/DebuggerWebAudioDomain';
import {Observer} from '../utils/Observer';
import {
CounterSubject,
DebuggerAttachEventController,
PermissionSubject,
} from './DebuggerAttachEventController';
import {Audion} from './Types';
/**
* Permission value in regards to calling `chrome.debugger.attach`.
*
* When the extension calls `chrome.debugger.attach` a notification will display
* in devtools that the extension is debugging the tab. Attaching when the user
* does not expect it and then see this notification is not desired. The user
* needs to grant permission for the extension the privilege to attach, or
* reject prior permission.
*
* Permission could be implied when the extension's panel is opened.
*
* Permission should be rejected when the debugging notification is canceled or
* dismissed.
*
* Permission could be granted more explicitly by a panel component when the
* panel is visible but the extension does not have permission.
*
* WebAudioEventObserver will be instructed with rules like the above by other
* functions outside of this file.
*/
enum AttachPermission {
/**
* Initial value.
*
* When WebAudioEventObserver is created, it does not know if permission has
* been granted or not and should treat this as **not having** permission.
*/
UNKNOWN,
/**
* Permission has been granted by a user action. WebAudioEventObserver may
* attach to `chrome.debugger`.
*/
TEMPORARY,
/**
* Permission has been rejected. WebAudioEventObserver must not attach to
* `chrome.debugger`.
*/
REJECTED,
}
/**
* Value used to indicate if the `chrome.debugger` attachment and
* receiving `chrome.debugger.onEvent` events are "active".
*/
enum BinaryTransition {
DEACTIVATING = 'deactivating',
IS_INACTIVE = 'isInactive',
ACTIVATING = 'activating',
IS_ACTIVE = 'isActive',
}
/** Chrome Devtools Protocol version to attach to. */
const debuggerVersion = '1.3';
/** Chrome tab to attach the debugger to. */
const {tabId} = chrome.devtools.inspectedWindow;
/**
* @memberof Audion
* @alias WebAudioEventObserver
*/
export class WebAudioEventObserver extends Observer<Audion.WebAudioEvent> {
debuggerAttachController: DebuggerAttachEventController;
/** Does user permit extension to use `chrome.debugger`. */
permission: PermissionSubject;
/** How many subscriptions want to attach to `chrome.debugger`. */
@ -89,8 +26,9 @@ export class WebAudioEventObserver extends Observer<Audion.WebAudioEvent> {
/**
* Observe WebAudio events from chrome.debugger.
* @param debuggerAttachController
*/
constructor() {
constructor(debuggerAttachController: DebuggerAttachEventController) {
super((onNext, ...args) => {
/**
* @param {Chrome.DebuggerDebuggee} debuggeeId
@ -121,113 +59,11 @@ export class WebAudioEventObserver extends Observer<Audion.WebAudioEvent> {
};
});
// Create an interface of subjects to track changes in state with the
// `chrome.debugger` api.
const debuggerSubject = {
// Does the extension have permission from the user to use `chrome.debugger` api.
permission: new PermissionSubject(),
// How many entities want to attach to the debugger to call `sendCommand`
// or listen to `onEvent`.
attachInterest: new CounterSubject(0),
// attachState must be IS_ACTIVE for `chrome.debugger.sendCommand` to be used.
attachState: new BinaryTransitionSubject({
initialState: BinaryTransition.IS_INACTIVE,
activateAction: () => attach({tabId}, debuggerVersion),
deactivateAction: () => detach({tabId}),
}),
// How many entities want to listen to `onEvent`.
eventInterest: new CounterSubject(0),
// eventState must be IS_ACTIVE for `onEvent` to receive events.
eventState: new BinaryTransitionSubject({
initialState: BinaryTransition.IS_INACTIVE,
activateAction: () =>
sendCommand({tabId}, WebAudioDebuggerMethod.enable),
deactivateAction: () =>
sendCommand({tabId}, WebAudioDebuggerMethod.disable),
}),
};
this.permission = debuggerSubject.permission;
this.attachInterest = debuggerSubject.attachInterest;
this.eventInterest = debuggerSubject.eventInterest;
this.debuggerAttachController = debuggerAttachController;
// Observable of changes to state derived from debuggerSubject.
const debuggerState$ =
// Push objects mapping of keys in debuggerSubject to values pushed from
// that debuggerSubject member.
combineLatest(debuggerSubject).pipe(
// Filter out combined state that is not different from the last value.
distinctUntilChanged(
(previous, current) =>
previous.permission === current.permission &&
previous.attachInterest === current.attachInterest &&
previous.attachState === current.attachState &&
previous.eventInterest === current.eventInterest &&
previous.eventState === current.eventState,
),
// Make one subscription debuggerSubject once for many subscribers.
share(),
);
// The following subscriptions govern debuggerSubject.
// Govern attachment to `chrome.debugger`.
debuggerState$.subscribe({
next: (state) => {
// When debugger state has permission to attach to `chrome.debugger` and
// something wants to use `chrome.debugger`, activate the attachment.
// Otherwise deactivate the attachment.
if (
state.permission === AttachPermission.TEMPORARY &&
state.attachInterest > 0
) {
debuggerSubject.attachState.activate();
} else {
debuggerSubject.attachState.deactivate();
}
},
});
// Govern permission rejection and externally induced detachment.
onDebuggerDetach$.subscribe({
next([, reason]) {
if (reason === 'canceled_by_user') {
// Reject permission to use `chrome.debugger` in this extension. We
// understand this event to be an explicit rejection from the
// extension's user.
debuggerSubject.permission.reject();
}
// Immediately go to the inactive state. Detachment was initiated
// outside the extension and does not need to be requested.
debuggerSubject.attachState.next(BinaryTransition.IS_INACTIVE);
},
});
// Govern receiving events through `chrome.debugger.onEvent`.
debuggerState$.subscribe({
next(state) {
if (
state.attachState === BinaryTransition.IS_ACTIVE &&
state.eventInterest > 0
) {
// Start receiving events. The attachemnt is active and some entities
// are listeneing for events.
debuggerSubject.eventState.activate();
} else {
if (state.attachState === BinaryTransition.IS_ACTIVE) {
// Stop receiving events. The attachment is still active but no
// entities are listening for events.
debuggerSubject.eventState.deactivate();
} else {
// "Skip" deactivation of receiving events and immediately go to
// the inactive state. The process of detachment either requested by
// the extension or initiated otherwise has implicitly stopped
// reception of events.
debuggerSubject.eventState.next(BinaryTransition.IS_INACTIVE);
}
}
},
});
this.permission = debuggerAttachController.permission$;
this.attachInterest = debuggerAttachController.attachInterest$;
this.eventInterest = debuggerAttachController.webAudioEventInterest$;
}
/**
@ -237,230 +73,3 @@ export class WebAudioEventObserver extends Observer<Audion.WebAudioEvent> {
this.permission.grantTemporary();
}
}
/**
* Create a function that returns an observable that completes when the api
* calls back.
* @param method `chrome` api method whose last argument is a callback
* @param thisArg `this` inside of the method
* @returns observable that completes when the method is done
*/
function bindChromeCallback<P extends any[]>(
method: (...args: [...params: P, callback: () => void]) => void,
thisArg = null,
) {
return (...args: P) =>
new Observable<void>((subscriber) => {
method.call(thisArg, ...args, () => {
if (chrome.runtime.lastError) {
subscriber.error(chrome.runtime.lastError);
} else {
subscriber.complete();
}
});
});
}
/**
* Return an observable that pushes events from a `chrome` api event.
* @param event `chrome` api event
* @returns observable of `chrome` api events
*/
function fromChromeEvent<A extends any[]>(
event: Chrome.Event<(...args: A) => any>,
) {
return new Observable<A>((subscriber) => {
const listener = (...args: A) => {
subscriber.next(args);
};
event.addListener(listener);
return () => {
event.removeListener(listener);
};
});
}
/**
* Call `chrome.debugger.attach`.
*
* @see
* https://developer.chrome.com/docs/extensions/reference/debugger/#method-attach
*/
const attach = bindChromeCallback(chrome.debugger.attach, chrome.debugger);
/**
* Call `chrome.debugger.detach`.
*
* @see
* https://developer.chrome.com/docs/extensions/reference/debugger/#method-detach
*/
const detach = bindChromeCallback(chrome.debugger.detach, chrome.debugger);
/**
* Call `chrome.debugger.sendCommand`.
*
* @see
* https://developer.chrome.com/docs/extensions/reference/debugger/#method-sendCommand
*/
const sendCommand = bindChromeCallback(
chrome.debugger.sendCommand as (
target: Chrome.DebuggerDebuggee,
method: WebAudioDebuggerMethod,
params?,
callback?,
) => void,
chrome.debugger,
);
/**
* Observable of `chrome.debugger.onDetach` events.
*/
const onDebuggerDetach$ = fromChromeEvent<
[target: Chrome.DebuggerDebuggee, reason: string]
>(chrome.debugger.onDetach);
/**
* Store if user allows the extension to use `chrome.debugger` api.
*/
class PermissionSubject extends BehaviorSubject<AttachPermission> {
constructor() {
super(AttachPermission.UNKNOWN);
}
/**
* Permit use of `chrome.debugger`.
*/
grantTemporary() {
if (this.value === AttachPermission.UNKNOWN) {
this.next(AttachPermission.TEMPORARY);
}
}
/**
* Reject use of `chrome.debugger`.
*/
reject() {
if (this.value !== AttachPermission.REJECTED) {
this.next(AttachPermission.REJECTED);
}
}
}
/**
* Description of a transition in BinaryTransitionSubject.
*/
interface BinaryTransitionDescription {
/** The state the Subject must start in to perform this transition. */
beginningState: BinaryTransition;
/** The state the Subject is in while performing this transition. */
intermediateState: BinaryTransition;
/** The state the Subject is in after action is successfully. */
successState: BinaryTransition;
/** The state the Subject is in after action is unsuccessful. */
errorState: BinaryTransition;
/**
* Delegate that does some work to modify other application state to the
* desired state.
*/
action: () => Observable<void>;
}
/**
* Control a transition between inactive and active state. To perform a
* transition the subject enters a intermediate state and calls a delegate to do
* some action. After the action completes successfully the subject enters the
* desired state.
*/
class BinaryTransitionSubject extends BehaviorSubject<BinaryTransition> {
private readonly activateTransition: BinaryTransitionDescription;
private readonly deactivateTransition: BinaryTransitionDescription;
constructor({
initialState,
activateAction,
deactivateAction,
}: {
initialState: BinaryTransition;
activateAction: () => Observable<void>;
deactivateAction: () => Observable<void>;
}) {
super(initialState);
this.activateTransition = {
beginningState: BinaryTransition.IS_INACTIVE,
intermediateState: BinaryTransition.ACTIVATING,
successState: BinaryTransition.IS_ACTIVE,
errorState: BinaryTransition.IS_INACTIVE,
action: activateAction,
};
this.deactivateTransition = {
beginningState: BinaryTransition.IS_ACTIVE,
intermediateState: BinaryTransition.DEACTIVATING,
successState: BinaryTransition.IS_INACTIVE,
errorState: BinaryTransition.IS_INACTIVE,
action: deactivateAction,
};
}
/**
* Transition to a desired state.
*
* Change the subject value if it is set to beginningState to intermediateState and once action completes successfuly, set to successState.
* @param description
*/
transition(description: BinaryTransitionDescription) {
if (this.value === description.beginningState) {
concat(
of(description.intermediateState),
description.action(),
defer(() =>
this.value === description.intermediateState
? of(description.successState)
: of(),
),
)
.pipe(
catchError((err) =>
of(
this.value === description.intermediateState
? description.errorState
: description.beginningState,
),
),
)
.subscribe({next: this.next.bind(this)});
}
}
/**
* If subject is inactive, transition to active.
*/
activate() {
this.transition(this.activateTransition);
}
/**
* If subject is active, transition to inactive.
*/
deactivate() {
this.transition(this.deactivateTransition);
}
}
/**
* Observable counting some discrete value.
*/
class CounterSubject extends BehaviorSubject<number> {
/**
* Increase value by 1.
*/
increment() {
this.next(this.value + 1);
}
/**
* Decrease value by 1.
*/
decrement() {
this.next(this.value - 1);
}
}

Просмотреть файл

@ -1,5 +1,6 @@
import Protocol from 'devtools-protocol';
import {bindCallback, concatMap, interval, map} from 'rxjs';
import {bindCallback, concatMap, interval} from 'rxjs';
import {map} from 'rxjs/operators';
import {invariant} from '../utils/error';

Просмотреть файл

@ -1,4 +1,5 @@
import {Observer} from '../utils/Observer';
import {DebuggerAttachEventController} from './DebuggerAttachEventController';
import {DevtoolsGraphPanel} from './DevtoolsGraphPanel';
import {serializeGraphContext} from './serializeGraphContext';
@ -6,7 +7,8 @@ import {WebAudioEventObserver} from './WebAudioEventObserver';
import {WebAudioGraphIntegrator} from './WebAudioGraphIntegrator';
import {WebAudioRealtimeData} from './WebAudioRealtimeData';
const webAudioEvents = new WebAudioEventObserver();
const debuggerAttachController = new DebuggerAttachEventController();
const webAudioEvents = new WebAudioEventObserver(debuggerAttachController);
const webAudioRealtimeData = new WebAudioRealtimeData();
const integrateMessages = new WebAudioGraphIntegrator(
webAudioEvents,
@ -47,3 +49,13 @@ panel.onShow = () => {
panel.onShow = null;
webAudioEvents.attach();
};
panel.requests$.subscribe({
next(value) {
if (value.type === 'collectGarbage') {
debuggerAttachController
.sendCommand('HeapProfiler.collectGarbage')
.subscribe();
}
},
});

Просмотреть файл

@ -1,4 +1,4 @@
<html class="-theme-with-dark-background">
<html>
<head>
<style>
body {
@ -293,6 +293,34 @@
flex: 1;
flex-direction: column;
}
.web-audio-content-panel {
display: flex;
flex: 1;
flex-direction: row;
}
.web-audio-detail-panel-container {
min-width: 20rem;
flex: 0;
background-color: var(--color-background-elevation-0);
position: relative;
}
.web-audio-detail-panel {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
overflow-x: hidden;
overflow-y: auto;
}
.web-audio-select-graph-dropdown {
position: absolute;
top: 0;
left: 0;
min-width: 20rem;
max-width: 20rem;
background-color: var(--color-background-elevation-1);
}
.toolbar-shadow {
position: relative;
white-space: nowrap;
@ -302,8 +330,19 @@
flex: none;
align-items: center;
}
.toolbar-dropdown {
height: 100%;
display: flex;
align-items: center;
cursor: pointer;
padding: 0 0.2rem;
}
.toolbar-dropdown:hover {
background: var(--color-background-elevation-2);
}
.dropdown-title,
.dropdown-button {
content: '&#9660;';
display: inline-block;
}
.web-audio-debug {
flex: 0 0 200px;
@ -332,16 +371,23 @@
<div class="web-audio-container">
<div class="web-audio-toolbar-container vbox">
<div class="toolbar-shadow">
<div class="toolbar-button"></div>
<div class="toolbar-button toolbar-garbage-button"></div>
<div class="toolbar-divider"></div>
<div class="toolbar-dropdown">
<div class="dropdown-title">(no recordings)</div>
<div class="dropdown-button"></div>
<div>
<div class="dropdown-title">(no recordings)</div>
<div class="dropdown-button">&blacktriangledown;</div>
</div>
</div>
</div>
</div>
<div class="web-audio-content-container">
<div class="web-audio-graph"></div>
<div class="web-audio-content-panel">
<div class="web-audio-detail-panel-container">
<div class="web-audio-detail-panel"></div>
</div>
<div class="web-audio-graph"></div>
</div>
<div class="web-audio-status"></div>
</div>
<div class="web-audio-open-page-instruction full-box hidden">
@ -355,12 +401,12 @@
</div>
</div>
</div>
<div class="web-audio-select-graph-dropdown hidden"></div>
<div class="web-audio-loading full-box">
<div>
<div><p>Loading ...</p></div>
</div>
</div>
<div class="web-audio-select-graph-dropdown hidden"></div>
<script src="panel.js"></script>
</body>
</html>

Просмотреть файл

@ -1,23 +1,11 @@
/// <reference path="../devtools/Types.ts" />
import {Observable, merge} from 'rxjs';
import {map, shareReplay, scan} from 'rxjs/operators';
import {
Observable,
map,
startWith,
withLatestFrom,
share,
Subscriber,
concat,
zip,
shareReplay,
merge,
scan,
} from 'rxjs';
import {Audion} from '../devtools/Types';
import {Utils} from '../utils/Types';
import {Observer} from '../utils/Observer';
import {toRX} from '../utils/toRX';
import {toRX} from '../utils/rxInterop';
type GraphMap = {[key: string]: Audion.GraphContext};

Просмотреть файл

@ -1,22 +0,0 @@
/// <reference path="../utils/Types.ts" />
import {chrome} from '../chrome';
import {Observer} from '../utils/Observer';
/**
* Connect to chrome runtime through an observer.
* @return {Utils.Observer<T>}
* @template T
*/
export function connect() {
return new Observer((onNext, ...args) => {
const port = chrome.runtime.connect();
port.onMessage.addListener((message) => {
onNext(message);
});
return () => {
port.disconnect();
};
});
}

Просмотреть файл

@ -0,0 +1,42 @@
import {Observable} from 'rxjs';
import {share} from 'rxjs/operators';
import {chrome} from '../chrome';
/**
* Connect to chrome runtime through an observable.
* @param requests$ observable of requests to send to devtools extension context
* @returns observable of messages recevied from devtools extension context
*/
export function connect<S, T>(requests$: Observable<S>): Observable<T> {
return new Observable<T>((subscriber) => {
const port = chrome.runtime.connect();
// Send values pushed by requests$ to devtools context.
const subjectSubscription = requests$.subscribe({
next(value) {
port.postMessage(value);
},
});
// Publish messages from devtools context through returned observable.
const onMessage: (arg0: any, arg1: Chrome.RuntimePort) => void = (
message,
) => {
subscriber.next(message);
};
const onDisconnect = () =>
subscriber.error(new Error('chrome.runtime disconnected'));
port.onMessage.addListener(onMessage);
port.onDisconnect.addListener(onDisconnect);
return () => {
subjectSubscription.unsubscribe();
port.onMessage.removeListener(onMessage);
port.onDisconnect.removeListener(onDisconnect);
port.disconnect();
};
}).pipe(share());
}

Просмотреть файл

@ -1,4 +1,5 @@
import {fromEvent} from 'rxjs';
import style from './WholeGraphButton.css';
import wholeGraphButtonImage from './WholeGraphButton.svg';

Просмотреть файл

@ -0,0 +1,15 @@
:global(.-theme-with-dark-background) .collectIcon {
--override-icon-mask-background-color: rgb(145 145 145);
}
.collectIcon {
display: inline-block;
-webkit-mask: url('./collectGarbage.svg') no-repeat center;
mask: url('./collectGarbage.svg') no-repeat center;
width: 28px;
height: 24px;
background-color: var(--override-icon-mask-background-color);
--override-icon-mask-background-color: rgb(110 110 110);
}
:global(.toolbar-button):hover .collectIcon {
background-color: var(--color-text-primary);
}

Просмотреть файл

@ -1,2 +1,41 @@
// TODO: export a observable operator function factory to render the garbage
// button and push observations when it is clicked.
import {fromEvent, merge, NEVER, Observable} from 'rxjs';
import {map, startWith, switchMap} from 'rxjs/operators';
import {Audion} from '../../devtools/Types';
import {setElementHTML} from './domUtils';
import style from './collectGarbage.css';
/**
* @returns html representation of the collect garbage icon
*/
function collectGarbageImageHTML(): string {
return `<span class="${style.collectIcon}"></span>`;
}
/**
* @param buttonElement$ observable of html elements to listen to events and
* render a icon in
* @returns observable of elements when they are modified or actions to be acted
* on by the extension's devtools context
*/
export function renderCollectGarbage(
buttonElement$: Observable<HTMLElement>,
): Observable<HTMLElement | Audion.DevtoolsCollectGarbageRequest> {
// Map clicks to actions to request devtools to collect garbage.
const collectGarbageAction$ = buttonElement$.pipe(
switchMap((element) => fromEvent(element, 'click')),
map(
() => ({type: 'collectGarbage'} as Audion.DevtoolsCollectGarbageRequest),
),
);
// Observable that pushs the button icon once and never completes. If the
// observable completes, setElementHTML will clean up and remove the html.
const collectGarbageIcon$ = NEVER.pipe(startWith(collectGarbageImageHTML()));
return merge(
setElementHTML(buttonElement$, collectGarbageIcon$),
collectGarbageAction$,
);
}

Просмотреть файл

@ -0,0 +1,23 @@
.detailPanel > * {
padding: 0 1rem;
}
.detailPanel h1,
.detailPanel h2,
.detailPanel h3,
.detailPanel h4,
.detailPanel h5,
.detailPanel h6 {
font-weight: normal;
}
.detailPanel table {
font-size: 12px;
}
.detailPanel th {
color: var(--color-text-secondary);
font-weight: normal;
text-align: left;
}
.detailPanel th,
.detailPanel td {
padding: 0.2rem;
}

Просмотреть файл

@ -0,0 +1,140 @@
import {merge, NEVER, Observable} from 'rxjs';
import {distinctUntilChanged, map, startWith, switchMap} from 'rxjs/operators';
import {Audion} from '../../devtools/Types';
import {setElementHTML, toggleElementClassList} from './domUtils';
import style from './detailPanel.css';
/**
* @param context web audio context's context information
* @returns html representation of context information
*/
function graphContextHTML({
contextType,
contextId,
contextState,
sampleRate,
callbackBufferSize,
maxOutputChannelCount,
}: Audion.GraphContext['context']): string {
return `<h2>${contextType}</h2>
<p>${contextId}</p>
<hr>
<table cellspacing="0" cellpadding="0">
<tr><th>State</th><td>${contextState}</td></tr>
<tr><th>Sample Rate</th><td>${sampleRate}</td></tr>
<tr><th>Callback Buffer Size</th><td>${callbackBufferSize}</td></tr>
<tr><th>Max Output Channels</th><td>${maxOutputChannelCount}</td></tr>
</table>
`;
}
/**
* @param node web audio node's node information
* @returns html representation of web audio node information
*/
function graphNodeBaseHTML({
nodeType,
nodeId,
channelCount,
channelCountMode,
channelInterpretation,
numberOfInputs,
numberOfOutputs,
}: Audion.GraphNode['node']): string {
return `<h2>${nodeType}</h2>
<p>${nodeId}</p>
<hr>
<table cellspacing="0" cellpadding="0">
<tr><th>Channel Count</th><td>${channelCount}</td></tr>
<tr><th>Channel Count Mode</th><td>${channelCountMode}</td></tr>
<tr><th>Channel Interpretation</th><td>${channelInterpretation}</td></tr>
<tr><th>Number of Inputs</th><td>${numberOfInputs}</td></tr>
<tr><th>Number of Outputs</th><td>${numberOfOutputs}</td></tr>
</table>
`;
}
/**
* @param param web audio node's single parameter information
* @returns html representation of parameter information
*/
function graphParamHTML({
paramType,
paramId,
rate,
defaultValue,
minValue,
maxValue,
}: Audion.GraphNode['params'][number]): string {
return `<h4>${paramType}</h4>
<p>${paramId}</p>
<hr>
<table cellspacing="0" cellpadding="0">
<tr><th>Automation Rate</th><td>${rate}</td></tr>
<tr><th>Default Value</th><td>${defaultValue}</td></tr>
<tr><th>Minimum Value</th><td>${minValue}</td></tr>
<tr><th>Maximum Value</th><td>${maxValue}</td></tr>
</table>
`;
}
/**
* @param node web audio node
* @returns html representation of a node's node and parameters information
*/
function graphNodeHTML({node, params}: Audion.GraphNode): string {
return `${graphNodeBaseHTML(node)}
${
params.length
? `<h3>Parameters:</h3>
${params.map(graphParamHTML).join('')}`
: ''
}
`;
}
/**
* @param element$ observable of html element to render detail panel into
* @param contextData$ observable of context data to render
* @param nodeData$ observable of node data to render
* @returns observable of html elements as they are modified
*/
export function renderDetailPanel(
element$: Observable<HTMLElement>,
contextData$: Observable<Audion.GraphContext>,
nodeData$: Observable<Audion.GraphNode>,
): Observable<HTMLElement> {
return merge(
toggleElementClassList(
element$,
NEVER.pipe(startWith([style.detailPanel])),
),
setElementHTML(
element$,
contextData$.pipe(
distinctUntilChanged((previous, current) =>
previous && previous.context && current && current.context
? previous.context.contextId === current.context.contextId
: false,
),
switchMap((graphContext) =>
nodeData$.pipe(
distinctUntilChanged((previous, current) =>
previous && previous.node && current && current.node
? previous.node.nodeId === current.node.nodeId
: false,
),
map((graphNode) =>
graphNode && graphNode.node
? graphNodeHTML(graphNode)
: graphContext && graphContext.context
? graphContextHTML(graphContext.context)
: '(no recordings)',
),
),
),
),
),
);
}

Просмотреть файл

@ -1,14 +1,20 @@
import {defer, finalize, map, Observable, of, switchMap} from 'rxjs';
import {defer, Observable, of} from 'rxjs';
import {finalize, map, scan, switchMap} from 'rxjs/operators';
/**
* Create a factory that modifies the most latest element from an observable of elements to value from an observable of other values.
* @param property html element property
* @returns factory that modifies a latest element with the latest data
*/
export function setElementProperty<
E extends HTMLElement,
K extends keyof E,
T extends E[K],
>(property: K) {
return function (element$: Observable<E>, property$: Observable<T>) {
return function (element$: Observable<E>, data$: Observable<T>) {
return element$.pipe(
switchMap((view) =>
property$.pipe(
data$.pipe(
map((value) => {
if (view) {
view[property] = value;
@ -26,10 +32,180 @@ export function setElementProperty<
};
}
/**
* Set that values can be added to and removed from.
*/
interface PropertySet<T> {
add(value: T): any;
remove(value: T): any;
}
/**
* Description of a change to a PropertySet.
*/
interface PropertySetChange {
/** Items to remove from the PropertySet. */
deleteItems: string[];
/** Items to add to the PropertySet. */
addItems: string[];
/** All items to remove if the element changes or finalizes. */
allItems: string[];
}
/**
* Create a factory that adds and removes the items contained in a observable of
* array values to the latest element.
* @param property html element property
* @returns factory that adds and removes items on an elements property
*/
export function toggleElementPropertySet<
E extends HTMLElement,
K extends {
[key in keyof E]: E[key] extends PropertySet<string> ? key : never;
}[any],
T extends string[],
>(property: K) {
return function (element$: Observable<E>, data$: Observable<T>) {
const valueDiff$ = data$.pipe(
scan(
([previous], current) => {
const allItems = current;
const deleteItems = previous.filter(
(value) => !current.includes(value),
);
const addItems = allItems.filter(
(value) => !previous.includes(value),
);
return [current, {deleteItems, addItems, allItems}] as [
T,
PropertySetChange,
];
},
[[], {deleteItems: [], addItems: []}] as [T, PropertySetChange],
),
map(([, change]) => change),
);
return element$.pipe(
switchMap((view) =>
valueDiff$.pipe(
map((diff) => {
if (view) {
for (const value of diff.deleteItems) {
(view[property] as PropertySet<string>).remove(value);
}
for (const value of diff.addItems) {
(view[property] as PropertySet<string>).add(value);
}
}
return view;
}),
finalize(() => {}),
),
),
);
};
}
/**
* Change to a html element property's map structure.
*/
interface PropertyMapChange {
/** Keys to remove from the property's map. */
deleteKeys: string[];
/** Keys to change to a given value. */
setKeys: [string, any][];
/** All keys. Used to remove all keys when the element changes or finalizes. */
allKeys: string[];
}
export function assignElementProperty<
E extends HTMLElement,
K extends keyof E,
T extends {[key in keyof E[K]]?: E[K][key]},
>(property: K) {
return function (element$: Observable<E>, data$: Observable<T>) {
const valueDiff$ = data$.pipe(
scan(
([previous], current) => {
const allKeys = Object.keys(current);
const deleteKeys = Object.keys(previous).filter(
(key) => !(key in current),
);
const setKeys = allKeys
.filter((key) => current[key] !== previous[key])
.map((key) => [key, current[key]]);
return [current, {deleteKeys, setKeys, allKeys}] as [
T,
PropertyMapChange,
];
},
[{}, {deleteKeys: [], setKeys: []}] as [T, PropertyMapChange],
),
map(([, change]) => change),
);
return element$.pipe(
switchMap((view) => {
let finalizeKeys = [];
return valueDiff$.pipe(
map((diff) => {
if (view) {
for (const key of diff.deleteKeys) {
view[property][key] = undefined;
}
for (const [key, value] of diff.setKeys) {
view[property][key] = value;
}
finalizeKeys = diff.allKeys;
}
return view;
}),
finalize(() => {
if (view) {
for (const key of finalizeKeys) {
view[property][key] = undefined;
}
}
}),
);
}),
);
};
}
/**
* Set latest element's innerText property to latest data string value.
*/
export const setElementText = setElementProperty('innerText');
/**
* Set latest element's innerHTML property to latest data string value.
*/
export const setElementHTML = setElementProperty('innerHTML');
/**
* Set latest element's className property to latest data string value.
*/
export const setElementClassName = setElementProperty('className');
/**
* Add and remove latest data string array to latest element's classList set
* property.
*/
export const toggleElementClassList = toggleElementPropertySet('classList');
/**
* Set and delete changes keys of latest data object to latest element's style
* object map property.
*/
export const assignElementStyle = assignElementProperty('style');
/**
* @param query css query selector to find an element for
* @param dom document to query
* @returns observable of a html element matching the query
*/
export function querySelector(
query: string,
dom: {querySelector(...args: any): any} = document,

Просмотреть файл

@ -1,4 +1,5 @@
import {map, Observable} from 'rxjs';
import {Audion} from '../../devtools/Types';
import {setElementHTML} from './domUtils';

Просмотреть файл

@ -0,0 +1,12 @@
.dropdownOption {
display: flex;
height: 2rem;
align-items: center;
cursor: pointer;
padding: 0 0.2rem;
}
.dropdownOption:hover,
.dropdownButtonActive {
background: var(--color-background-elevation-2);
}

Просмотреть файл

@ -1,12 +1,28 @@
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
map,
fromEvent,
merge,
Observable,
of,
} from 'rxjs';
import {
distinctUntilChanged,
filter,
map,
switchMap,
tap,
} from 'rxjs/operators';
import {Audion} from '../../devtools/Types';
import {setElementHTML, setElementText} from './domUtils';
import {
assignElementStyle,
setElementClassName,
setElementHTML,
setElementText,
toggleElementClassList,
} from './domUtils';
import style from './selectGraph.css';
/**
* Title of the dropdown toggle button when no graphs are selected or available
@ -68,8 +84,11 @@ function buttonTitle([graphId, graphTitles]) {
const dropdownListHTML = function (graphTitles: {
[graphId: string]: string;
}): string {
return Object.values(graphTitles)
.map((title) => `<span>${title}</span>`)
return Object.entries(graphTitles)
.map(
([graphId, title]) =>
`<div class="${style.dropdownOption}" data-option="${graphId}"><div class="${style.dropdownOptionTitle}">${title}</div></div>`,
)
.join('');
};
@ -106,6 +125,7 @@ function equalTitles(
* title into
* @param dropdownListElement$ current html element to render dropdown
* list into
* @param buttonElement$ current html element that when clicked opens the dropdown
* @param graphId$ currently selected graph id
* @param allGraphs$ current map of graph ids to graph contexts
* @returns an element pushed to renderSelectGraph after its content is modified
@ -113,6 +133,7 @@ function equalTitles(
export function renderSelectGraph(
titleElement$: Observable<HTMLElement>,
dropdownListElement$: Observable<HTMLElement>,
buttonElement$: Observable<HTMLElement>,
graphId$: Observable<string>,
allGraphs$: Observable<Audion.GraphContextsById>,
) {
@ -123,10 +144,92 @@ export function renderSelectGraph(
);
const graphIdAndTitles$ = combineLatest([distinctGraphId$, graphTitles$]);
const dropdownVisible$ = new BehaviorSubject(false);
const body$ = of(document.body);
const bodyClick$ = body$.pipe(
switchMap((element) => fromEvent(element, 'click')),
);
const openDropdownAction$ = buttonElement$.pipe(
switchMap((element) => fromEvent(element, 'click')),
tap(() => dropdownVisible$.next(!dropdownVisible$.value)),
filter(() => false),
map(() => {}),
);
const closeDropdownAction$ = combineLatest([
buttonElement$,
dropdownListElement$,
]).pipe(
switchMap(([buttonElement, dropdownElement]) =>
bodyClick$.pipe(
filter(
(ev) =>
ev.target instanceof Element &&
!(
buttonElement.contains(ev.target) ||
dropdownElement.contains(ev.target)
),
),
),
),
tap(() => dropdownVisible$.next(false)),
filter(() => false),
map(() => {}),
);
const eventAction$ = merge(openDropdownAction$, closeDropdownAction$);
const titleText$ = graphIdAndTitles$.pipe(map(buttonTitle));
const buttonClassName$ = dropdownVisible$.pipe(
map((visible) => (visible ? [style.dropdownButtonActive] : [])),
);
const dropdownListHTML$ = graphTitles$.pipe(map(dropdownListHTML));
const dropdownListIdSelected$ = dropdownListElement$.pipe(
switchMap((element) => fromEvent(element, 'click')),
map((clickEvent) => {
let {target} = clickEvent;
if (target instanceof HTMLElement) {
const optionElement = target.closest('[data-option]');
if (optionElement instanceof HTMLElement) {
const graphId = optionElement.dataset['option'];
if (graphId) {
return {type: 'selectGraph', graphId};
}
}
}
}),
filter(Boolean),
tap(() => dropdownVisible$.next(false)),
);
const dropdownClassName$ = dropdownVisible$.pipe(
map(
(visible) => `web-audio-select-graph-dropdown ${visible ? '' : 'hidden'}`,
),
);
const dropdownPositionStyle$ = buttonElement$.pipe(
switchMap((buttonElement) =>
dropdownVisible$.pipe(
map((visible) => {
const rect = buttonElement.getBoundingClientRect();
return visible
? {
top: `${rect.bottom}px`,
left: `${rect.left}px`,
}
: {};
}),
),
),
);
return merge(
setElementText(titleElement$, titleText$),
toggleElementClassList(buttonElement$, buttonClassName$),
setElementHTML(dropdownListElement$, dropdownListHTML$),
setElementClassName(dropdownListElement$, dropdownClassName$),
assignElementStyle(dropdownListElement$, dropdownPositionStyle$),
dropdownListIdSelected$,
eventAction$,
);
}

Просмотреть файл

@ -1,6 +1,7 @@
/// <reference path="../../chrome/Types.js" />
import * as PIXI from 'pixi.js';
import {BehaviorSubject} from 'rxjs';
import {Audion} from '../../devtools/Types';
@ -27,6 +28,8 @@ export class AudioGraphRender {
renderFrameId: AnimationFrameId | null;
selectedNode$: BehaviorSubject<Audion.GraphNode>;
/**
* Create an AudioGraphRender.
* @param {object} options
@ -47,6 +50,8 @@ export class AudioGraphRender {
this.renderFrameId = null;
this.render = this.render.bind(this);
this.selectedNode$ = new BehaviorSubject<Audion.GraphNode>(null);
}
/** Initialize. */
@ -171,6 +176,26 @@ export class AudioGraphRender {
}
}
getNodeAtViewportPoint(viewportPoint: {x: number; y: number}) {
const screenPoint = new PIXI.Point(
viewportPoint.x * this.camera.screen.width,
viewportPoint.y * this.camera.screen.height,
);
return this.getNodeAtScreenPoint(screenPoint);
}
getNodeAtScreenPoint(screenPoint: {x: number; y: number}) {
for (const nodeRender of this.nodeMap.values()) {
if (
nodeRender.container.getBounds().contains(screenPoint.x, screenPoint.y)
) {
return nodeRender.node;
}
}
return null;
}
/** Initialize event handling. */
initEvents() {
const {pixiApplication: app} = this;
@ -189,7 +214,22 @@ export class AudioGraphRender {
}
});
app.view.onwheel = (/** @type {WheelEvent} */ e) => {
app.view.onclick = ({offsetX, offsetY}) => {
const {clientWidth, clientHeight} = app.view;
const viewportPoint = new PIXI.Point(
offsetX / clientWidth,
offsetY / clientHeight,
);
const lastSelectedNode = this.selectedNode$.value;
const selectedNode = this.getNodeAtViewportPoint(viewportPoint);
this.nodeMap.get(lastSelectedNode?.node?.nodeId)?.setHighlight(false);
this.nodeMap.get(selectedNode?.node?.nodeId)?.setHighlight(true);
this.selectedNode$.next(selectedNode);
};
app.view.onwheel = (e) => {
this.camera.zoom(
e.clientX - app.view.clientLeft,
e.clientY - app.view.clientTop,
@ -225,6 +265,10 @@ export class AudioGraphRender {
if (nodeRender) {
nodeRender.remove();
this.nodeMap.delete(nodeId);
if (nodeId === this.selectedNode$.value?.node?.nodeId) {
this.selectedNode$.next(null);
}
}
}

Просмотреть файл

@ -21,6 +21,7 @@ export class AudioNodeRender {
input: AudioNodePort[];
output: AudioNodePort[];
param: {[key: string]: AudioNodePort};
isHighlighted: boolean;
/**
* Create a AudioNodeRender instance.
@ -229,6 +230,11 @@ export class AudioNodeRender {
}
}
setHighlight(isHighlighted: boolean) {
this.isHighlighted = isHighlighted;
this.draw();
}
/**
* Update the rendering.
*/
@ -236,7 +242,11 @@ export class AudioNodeRender {
const {background, node} = this;
background.clear();
background.lineStyle(0);
if (this.isHighlighted) {
background.lineStyle({width: 5, color: 0x000000});
} else {
background.lineStyle(0);
}
background.beginFill(colorFromNodeType(node.node.nodeType));
background.drawRoundedRect(0, 0, this.size.x, this.size.y, 3);
background.endFill();

Просмотреть файл

@ -3,13 +3,15 @@
import * as PIXI from 'pixi.js';
// This module disable's pixi.js use of new Function to optimize rendering.
import {install} from '@pixi/unsafe-eval';
import {map, merge, scan, shareReplay} from 'rxjs';
import {merge, Subject} from 'rxjs';
import {filter, map, scan, shareReplay, tap} from 'rxjs/operators';
import {Audion} from '../devtools/Types';
import {Utils} from '../utils/Types';
import {Observer} from '../utils/Observer';
import {toRX} from '../utils/toRX';
import {toUtilsObserver} from '../utils/rxInterop';
import {
observeMessageEvents,
postObservations,
@ -23,14 +25,26 @@ import {WholeGraphButton} from './components/WholeGraphButton';
import {querySelector} from './components/domUtils';
import {renderRealtimeSummary} from './components/realtimeSummary';
import {renderSelectGraph} from './components/selectGraph';
import {renderDetailPanel} from './components/detailPanel';
import {chrome} from '../chrome';
import {renderCollectGarbage} from './components/collectGarbage';
// Install an alternate system to part of pixi.js rendering that does not use
// new Function.
install(PIXI);
const devtoolsObserver: Audion.DevtoolsObserver = connect();
if (chrome.devtools.panels.themeName === 'dark') {
document.querySelector('html').className = '-theme-with-dark-background';
}
const devtoolsObserver$ = toRX<Audion.DevtoolsMessage>(devtoolsObserver);
const devtoolsRequestSubject$ = new Subject<Audion.DevtoolsRequest>();
const devtoolsObserver$ = connect<
Audion.DevtoolsRequest,
Audion.DevtoolsMessage
>(devtoolsRequestSubject$);
const devtoolsObserver: Audion.DevtoolsObserver =
toUtilsObserver(devtoolsObserver$);
const allGraphsObserver: Utils.Observer<Audion.GraphContextsById> =
Observer.reduce(
@ -87,8 +101,15 @@ const graphSelector = new GraphSelector({
allGraphsObserver$,
});
graphSelector.optionsObserver.observe((options) => {
// Select the newest graph.
graphSelector.select(options[options.length - 1] || '');
if (
// Select a graph automatically if one is not selected.
graphSelector.graphId === '' ||
// Select a graph automatically if current selected graph is no longer available.
!options.includes(graphSelector.graphId)
) {
// Select the newest graph (the last in the list).
graphSelector.select(options[options.length - 1] || '');
}
});
const graphContainer =
@ -131,16 +152,47 @@ graphContainer.appendChild(wholeGraphButton.render());
graphRender.start();
merge(
renderCollectGarbage(querySelector('.toolbar-garbage-button')).pipe(
tap((action) => {
if (action && 'type' in action && action.type === 'collectGarbage') {
devtoolsRequestSubject$.next(action);
}
}),
filter(isHTMLElement),
),
renderSelectGraph(
querySelector('.web-audio-toolbar-container .dropdown-title'),
querySelector('.web-audio-select-graph-dropdown'),
querySelector('.web-audio-toolbar-container .toolbar-dropdown'),
graphSelector.graphIdObserver$,
allGraphsObserver$,
).pipe(
tap((action) => {
if (action && 'type' in action && action.type === 'selectGraph') {
graphSelector.select(action.graphId);
}
}),
filter(isHTMLElement),
),
renderRealtimeSummary(
querySelector('.web-audio-status'),
graphSelector.graphObserver$.pipe(map(({realtimeData}) => realtimeData)),
),
).subscribe();
renderDetailPanel(
querySelector('.web-audio-detail-panel'),
graphSelector.graphObserver$,
graphRender.selectedNode$,
),
)
// Observe elements as they are changed.
.subscribe();
document.getElementsByClassName('web-audio-loading')[0].classList.add('hidden');
/**
* @param value
* @returns value is a HTMLElement
*/
function isHTMLElement(value: unknown): value is HTMLElement {
return value && value instanceof HTMLElement;
}

Просмотреть файл

@ -1,19 +1,18 @@
import * as dagre from 'dagre';
import {fromEvent, Observable} from 'rxjs';
import {
filter,
map,
startWith,
throttleTime,
withLatestFrom,
} from 'rxjs/operators';
import {serializeGraphContext} from '../devtools/serializeGraphContext';
import {
deserializeGraphContext,
SerializedGraphContext,
} from '../devtools/deserializeGraphContext';
import {
filter,
fromEvent,
map,
Observable,
startWith,
throttleTime,
withLatestFrom,
} from 'rxjs';
interface LayoutOptionsMessage {
layoutOptions: dagre.GraphLabel;

Просмотреть файл

@ -1,5 +1,6 @@
import {Observable} from 'rxjs';
import {Observer} from './Observer';
import {Utils} from './Types';
/**
@ -20,3 +21,14 @@ export function toRX<T>(observer: Utils.Observer<T>): Observable<T> {
),
);
}
export function toUtilsObserver<T>(
observable: Observable<T>,
): Utils.Observer<T> {
return new Observer((next, complete, error) => {
const subscription = observable.subscribe({next, complete, error});
return () => {
subscription.unsubscribe();
};
});
}

Просмотреть файл

@ -16,16 +16,17 @@ module.exports = (env, argv) => ({
},
module: {
rules: [
{
test: /\.svg$/,
use: 'file-loader',
},
{
test: /\.css$/,
use: ['style-loader', {loader: 'css-loader', options: {modules: true}}],
},
{test: /\.tsx?$/, loader: 'ts-loader'},
{test: /\.js$/, loader: 'source-map-loader'},
{
test: /\.(png|jpe?g|gif|svg|eot|ttf|woff|woff2)$/i,
// More information here https://webpack.js.org/guides/asset-modules/
type: 'asset',
},
],
},
});