feature: add detail panel
This commit is contained in:
Родитель
d2f6ac0bd0
Коммит
fae1062b00
|
@ -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: '▼';
|
||||
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">▾</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',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
Загрузка…
Ссылка в новой задаче