Merge pull request #1796 from edx/jmclaus/feature_jsinput_template
Added JSInput template in Studio.
This commit is contained in:
@@ -456,11 +456,9 @@ registry.register(JavascriptInput)
|
||||
|
||||
class JSInput(InputTypeBase):
|
||||
"""
|
||||
DO NOT USE! HAS NOT BEEN TESTED BEYOND 700X PROBLEMS, AND MAY CHANGE IN
|
||||
BACKWARDS-INCOMPATIBLE WAYS.
|
||||
Inputtype for general javascript inputs. Intended to be used with
|
||||
Inputtype for general javascript inputs. Intended to be used with
|
||||
customresponse.
|
||||
Loads in a sandboxed iframe to help prevent css and js conflicts between
|
||||
Loads in a sandboxed iframe to help prevent css and js conflicts between
|
||||
frame and top-level window.
|
||||
|
||||
iframe sandbox whitelist:
|
||||
@@ -478,7 +476,8 @@ class JSInput(InputTypeBase):
|
||||
height="500"
|
||||
width="400"/>
|
||||
|
||||
See the documentation in the /doc/public folder for more information.
|
||||
See the documentation in docs/data/source/course_data_formats/jsinput.rst
|
||||
for more information.
|
||||
"""
|
||||
|
||||
template = "jsinput.html"
|
||||
@@ -498,12 +497,16 @@ class JSInput(InputTypeBase):
|
||||
Attribute('set_statefn', None), # Function to call iframe to
|
||||
# set state
|
||||
Attribute('width', "400"), # iframe width
|
||||
Attribute('height', "300") # iframe height
|
||||
Attribute('height', "300"), # iframe height
|
||||
Attribute('sop', None) # SOP will be relaxed only if this
|
||||
# attribute is set to false.
|
||||
]
|
||||
|
||||
def _extra_context(self):
|
||||
context = {
|
||||
'applet_loader': '{static_url}js/capa/src/jsinput.js'.format(
|
||||
'jschannel_loader': '{static_url}js/capa/src/jschannel.js'.format(
|
||||
static_url=self.system.STATIC_URL),
|
||||
'jsinput_loader': '{static_url}js/capa/src/jsinput.js'.format(
|
||||
static_url=self.system.STATIC_URL),
|
||||
'saved_state': self.value
|
||||
}
|
||||
|
||||
@@ -9,10 +9,14 @@
|
||||
% if set_statefn:
|
||||
data-setstate="${set_statefn}"
|
||||
% endif
|
||||
% if sop:
|
||||
data-sop="${sop}"
|
||||
% endif
|
||||
data-processed="false"
|
||||
>
|
||||
|
||||
|
||||
<div class="script_placeholder" data-src="${applet_loader}"/>
|
||||
<div class="script_placeholder" data-src="${jschannel_loader}"/>
|
||||
<div class="script_placeholder" data-src="${jsinput_loader}"/>
|
||||
% if status == 'unsubmitted':
|
||||
<div class="unanswered" id="status_${id}">
|
||||
% elif status == 'correct':
|
||||
|
||||
@@ -151,7 +151,7 @@ class @Problem
|
||||
# If some function wants to be called before sending the answer to the
|
||||
# server, give it a chance to do so.
|
||||
#
|
||||
# check_waitfor allows the callee to send alerts if the user's input is
|
||||
# check_save_waitfor allows the callee to send alerts if the user's input is
|
||||
# invalid. To do so, the callee must throw an exception named "Waitfor
|
||||
# Exception". This and any other errors or exceptions that arise from the
|
||||
# callee are rethrown and abort the submission.
|
||||
@@ -159,18 +159,23 @@ class @Problem
|
||||
# In order to use this feature, add a 'data-waitfor' attribute to the input,
|
||||
# and specify the function to be called by the check button before sending
|
||||
# off @answers
|
||||
check_waitfor: =>
|
||||
check_save_waitfor: (callback) =>
|
||||
for inp in @inputs
|
||||
if ($(inp).is("input[waitfor]"))
|
||||
try
|
||||
$(inp).data("waitfor")()
|
||||
@refreshAnswers()
|
||||
$(inp).data("waitfor")(() =>
|
||||
@refreshAnswers()
|
||||
callback()
|
||||
)
|
||||
catch e
|
||||
if e.name == "Waitfor Exception"
|
||||
alert e.message
|
||||
else
|
||||
alert "Could not grade your answer. The submission was aborted."
|
||||
throw e
|
||||
return true
|
||||
else
|
||||
return false
|
||||
|
||||
|
||||
###
|
||||
@@ -254,7 +259,10 @@ class @Problem
|
||||
$.ajaxWithPrefix("#{@url}/problem_check", settings)
|
||||
|
||||
check: =>
|
||||
@check_waitfor()
|
||||
if not @check_save_waitfor(@check_internal)
|
||||
@check_internal()
|
||||
|
||||
check_internal: =>
|
||||
Logger.log 'problem_check', @answers
|
||||
|
||||
# Segment.io
|
||||
@@ -334,6 +342,10 @@ class @Problem
|
||||
@el.find('.capa_alert').css(opacity: 0).animate(opacity: 1, 700)
|
||||
|
||||
save: =>
|
||||
if not @check_save_waitfor(@save_internal)
|
||||
@save_internal()
|
||||
|
||||
save_internal: =>
|
||||
Logger.log 'problem_save', @answers
|
||||
$.postWithPrefix "#{@url}/problem_save", @answers, (response) =>
|
||||
saveMessage = response.msg
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
metadata:
|
||||
display_name: Custom Javascript Display and Grading
|
||||
markdown: !!null
|
||||
data: |
|
||||
<problem display_name="webGLDemo">
|
||||
<script type="loncapa/python">
|
||||
import json
|
||||
def vglcfn(e, ans):
|
||||
'''
|
||||
par is a dictionary containing two keys, "answer" and "state"
|
||||
The value of answer is the JSON string returned by getGrade
|
||||
The value of state is the JSON string returned by getState
|
||||
'''
|
||||
par = json.loads(ans)
|
||||
# We can use either the value of the answer key to grade
|
||||
answer = json.loads(par["answer"])
|
||||
return answer["cylinder"] and not answer["cube"]
|
||||
'''
|
||||
# Or we could use the value of the state key
|
||||
state = json.loads(par["state"])
|
||||
selectedObjects = state["selectedObjects"]
|
||||
return selectedObjects["cylinder"] and not selectedObjects["cube"]
|
||||
'''
|
||||
</script>
|
||||
<p>
|
||||
The shapes below can be selected (yellow) or unselected (cyan).
|
||||
Clicking on them repeatedly will cycle through these two states.
|
||||
</p>
|
||||
<p>
|
||||
If the cone is selected (and not the cube), a correct answer will be
|
||||
generated after pressing "Check". Clicking on either "Check" or "Save"
|
||||
will register the current state.
|
||||
</p>
|
||||
<customresponse cfn="vglcfn">
|
||||
<jsinput gradefn="WebGLDemo.getGrade"
|
||||
get_statefn="WebGLDemo.getState"
|
||||
set_statefn="WebGLDemo.setState"
|
||||
width="400"
|
||||
height="400"
|
||||
html_file="https://studio.edx.org/c4x/edX/DemoX/asset/webGLDemo.html"
|
||||
sop="false"/>
|
||||
</customresponse>
|
||||
</problem>
|
||||
50
common/static/js/capa/fixtures/jsinput.html
Normal file
50
common/static/js/capa/fixtures/jsinput.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<section
|
||||
id="inputtype_1"
|
||||
data="getGrade"
|
||||
data-stored="{"answer":"{\"cylinder\":true,\"cube\":true}","state":"{\"selectedObjects\":{\"cylinder\":true,\"cube\":true}}"}"
|
||||
data-getstate="getState"
|
||||
data-setstate="setState"
|
||||
data-processed="false"
|
||||
data-sop="false"
|
||||
class="jsinput">
|
||||
|
||||
<div class="script_placeholder"/>
|
||||
<iframe
|
||||
name="iframe_1"
|
||||
sandbox="allow-scripts
|
||||
allow-popups
|
||||
allow-same-origin
|
||||
allow-forms
|
||||
allow-pointer-lock"
|
||||
height="500"
|
||||
width="500"
|
||||
src="https://studio.edx.org/c4x/edX/DemoX/asset/webGLDemo.html">
|
||||
</iframe>
|
||||
<input type="hidden" name="input_1" id="input_1" waitfor value="{"answer":"{\"cylinder\":true,\"cube\":true}","state":"{\"selectedObjects\":{\"cylinder\":true,\"cube\":true}}"}">
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
id="inputtype_2"
|
||||
data="getGrade"
|
||||
data-stored="{"answer":"{\"cylinder\":true,\"cube\":true}","state":"{\"selectedObjects\":{\"cylinder\":true,\"cube\":true}}"}"
|
||||
data-getstate="getState"
|
||||
data-setstate="setState"
|
||||
data-processed="false"
|
||||
data-sop="false"
|
||||
class="jsinput">
|
||||
|
||||
<div class="script_placeholder"/>
|
||||
<iframe
|
||||
name="iframe_2"
|
||||
sandbox="allow-scripts
|
||||
allow-popups
|
||||
allow-same-origin
|
||||
allow-forms
|
||||
allow-pointer-lock"
|
||||
height="500"
|
||||
width="500"
|
||||
src="https://studio.edx.org/c4x/edX/DemoX/asset/webGLDemo.html">
|
||||
</iframe>
|
||||
<input type="hidden" name="input_2" id="input_1" waitfor value="{"answer":"{\"cylinder\":true,\"cube\":true}","state":"{\"selectedObjects\":{\"cylinder\":true,\"cube\":true}}"}">
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,70 +1,30 @@
|
||||
xdescribe("A jsinput has:", function () {
|
||||
|
||||
describe("JSInput", function() {
|
||||
beforeEach(function () {
|
||||
$('#fixture').remove();
|
||||
$.ajax({
|
||||
async: false,
|
||||
url: 'mainfixture.html',
|
||||
success: function(data) {
|
||||
$('body').append($(data));
|
||||
}
|
||||
loadFixtures('js/capa/fixtures/jsinput.html');
|
||||
});
|
||||
|
||||
it('sets all data-processed attributes to true on first load', function() {
|
||||
var sections = $(document).find('section[id="inputtype_"]');
|
||||
JSInput.walkDOM();
|
||||
sections.each(function(index, section) {
|
||||
expect(section.attr('data-processed')).toEqual('true');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
describe("The jsinput constructor", function(){
|
||||
|
||||
var iframe1 = $(document).find('iframe')[0];
|
||||
|
||||
var testJsElem = jsinputConstructor({
|
||||
id: 1,
|
||||
elem: iframe1,
|
||||
passive: false
|
||||
});
|
||||
|
||||
it("Returns an object", function(){
|
||||
expect(typeof(testJsElem)).toEqual('object');
|
||||
});
|
||||
|
||||
it("Adds the object to the jsinput array", function() {
|
||||
expect(jsinput.exists(1)).toBe(true);
|
||||
});
|
||||
|
||||
describe("The returned object", function() {
|
||||
|
||||
it("Has a public 'update' method", function(){
|
||||
expect(testJsElem.update).toBeDefined();
|
||||
});
|
||||
|
||||
it("Returns an 'update' that is idempotent", function(){
|
||||
var orig = testJsElem.update();
|
||||
for (var i = 0; i++; i < 5) {
|
||||
expect(testJsElem.update()).toEqual(orig);
|
||||
}
|
||||
});
|
||||
|
||||
it("Changes the parent's inputfield", function() {
|
||||
testJsElem.update();
|
||||
|
||||
});
|
||||
});
|
||||
it('sets the data-processed attribute to true on subsequent load', function() {
|
||||
var section1 = $(document).find('section[id="inputtype_1"]'),
|
||||
section2 = $(document).find('section[id="inputtype_2"]');
|
||||
section1.attr('data-processed', false);
|
||||
JSInput.walkDOM();
|
||||
expect(section1.attr('data-processed')).toEqual('true');
|
||||
expect(section2.attr('data-processed')).toEqual('true');
|
||||
});
|
||||
|
||||
|
||||
describe("The walkDOM functions", function() {
|
||||
|
||||
walkDOM();
|
||||
|
||||
it("Creates (at least) one object per iframe", function() {
|
||||
jsinput.arr.length >= 2;
|
||||
});
|
||||
|
||||
it("Does not create multiple objects with the same id", function() {
|
||||
while (jsinput.arr.length > 0) {
|
||||
var elem = jsinput.arr.pop();
|
||||
expect(jsinput.exists(elem.id)).toBe(false);
|
||||
}
|
||||
it('sets the waitfor attribute to its update function', function() {
|
||||
var inputFields = $(document).find('input[id="input_"]');
|
||||
JSInput.walkDOM();
|
||||
inputFields.each(function(index, inputField) {
|
||||
expect(inputField.data('waitfor')).toBeDefined();
|
||||
});
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
<title> JSinput jasmine test </title>
|
||||
</head>
|
||||
<body>
|
||||
<section id="inputtype_1"
|
||||
data="gradefn"
|
||||
data-setstate="setinput"
|
||||
class="jsinput">
|
||||
|
||||
<div class="script_placeholder" />
|
||||
<iframe name="iframe_1"
|
||||
|
||||
sandbox="allow-scripts
|
||||
allow-popups
|
||||
allow-same-origin
|
||||
allow-forms
|
||||
allow-pointer-lock"
|
||||
seamless="seamless"
|
||||
height="500"
|
||||
width="500">
|
||||
<html>
|
||||
<head>
|
||||
<title>
|
||||
JS input test 1
|
||||
</title>
|
||||
<script type="text/javascript">
|
||||
function gradefn () {
|
||||
var ans = document.getElementById("one").value;
|
||||
console.log("I've been called!");
|
||||
return ans
|
||||
}
|
||||
|
||||
function setinput(val) {
|
||||
document.getElementById("one").value(val);
|
||||
return;
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<p>Simple js input test. Defines a js function that returns the value in
|
||||
the input field below when called. </p>
|
||||
|
||||
<form>
|
||||
<input id='one' type="TEXT"/>
|
||||
</form>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</iframe>
|
||||
<input type="hidden" name="input_1" id="input_1" value="${value|h}"/>
|
||||
|
||||
<br/>
|
||||
<button id="update_1" class="update">Update</button>
|
||||
<p id="answer_1" class="answer"></p>
|
||||
|
||||
<p class="status">
|
||||
</p>
|
||||
<br/> <br/>
|
||||
|
||||
<div class="error_message" ></div>
|
||||
|
||||
</section>
|
||||
<section id="inputtype_2" data="gradefn" class="jsinput">
|
||||
|
||||
<div class="script_placeholder" />
|
||||
<iframe name="iframe_2"
|
||||
sandbox="allow-scripts
|
||||
allow-popups
|
||||
allow-same-origin
|
||||
allow-forms
|
||||
allow-pointer-lock"
|
||||
seamless="seamless"
|
||||
height="500"
|
||||
width="500" >
|
||||
<html>
|
||||
<head>
|
||||
<title>
|
||||
JS input test
|
||||
</title>
|
||||
<script type="text/javascript">
|
||||
function gradefn () {
|
||||
var ans = document.getElementById("one").value;
|
||||
console.log("I've been called!");
|
||||
return ans
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<p>Simple js input test. Defines a js function that returns the value in
|
||||
the input field below when called. </p>
|
||||
|
||||
<form>
|
||||
<input id='two' type="TEXT"/>
|
||||
</form>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</iframe>
|
||||
<input type="hidden" name="input_2" id="input_2" value="${value|h}"/>
|
||||
|
||||
<br/>
|
||||
<button id="update_2" class="update">Update</button>
|
||||
<p id="answer_2" class="answer"></p>
|
||||
|
||||
<p class="status">
|
||||
</p>
|
||||
<br/> <br/>
|
||||
|
||||
<div class="error_message"></div>
|
||||
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
788
common/static/js/capa/src/jschannel.js
Normal file
788
common/static/js/capa/src/jschannel.js
Normal file
@@ -0,0 +1,788 @@
|
||||
/*
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version
|
||||
of the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
||||
*/
|
||||
|
||||
/*
|
||||
* js_channel is a very lightweight abstraction on top of
|
||||
* postMessage which defines message formats and semantics
|
||||
* to support interactions more rich than just message passing
|
||||
* js_channel supports:
|
||||
* + query/response - traditional rpc
|
||||
* + query/update/response - incremental async return of results
|
||||
* to a query
|
||||
* + notifications - fire and forget
|
||||
* + error handling
|
||||
*
|
||||
* js_channel is based heavily on json-rpc, but is focused at the
|
||||
* problem of inter-iframe RPC.
|
||||
*
|
||||
* Message types:
|
||||
* There are 5 types of messages that can flow over this channel,
|
||||
* and you may determine what type of message an object is by
|
||||
* examining its parameters:
|
||||
* 1. Requests
|
||||
* + integer id
|
||||
* + string method
|
||||
* + (optional) any params
|
||||
* 2. Callback Invocations (or just "Callbacks")
|
||||
* + integer id
|
||||
* + string callback
|
||||
* + (optional) params
|
||||
* 3. Error Responses (or just "Errors)
|
||||
* + integer id
|
||||
* + string error
|
||||
* + (optional) string message
|
||||
* 4. Responses
|
||||
* + integer id
|
||||
* + (optional) any result
|
||||
* 5. Notifications
|
||||
* + string method
|
||||
* + (optional) any params
|
||||
*/
|
||||
|
||||
;var Channel = (function() {
|
||||
"use strict";
|
||||
|
||||
// current transaction id, start out at a random *odd* number between 1 and a million
|
||||
// There is one current transaction counter id per page, and it's shared between
|
||||
// channel instances. That means of all messages posted from a single javascript
|
||||
// evaluation context, we'll never have two with the same id.
|
||||
var s_curTranId = Math.floor(Math.random()*1000001);
|
||||
|
||||
// no two bound channels in the same javascript evaluation context may have the same origin, scope, and window.
|
||||
// futher if two bound channels have the same window and scope, they may not have *overlapping* origins
|
||||
// (either one or both support '*'). This restriction allows a single onMessage handler to efficiently
|
||||
// route messages based on origin and scope. The s_boundChans maps origins to scopes, to message
|
||||
// handlers. Request and Notification messages are routed using this table.
|
||||
// Finally, channels are inserted into this table when built, and removed when destroyed.
|
||||
var s_boundChans = { };
|
||||
|
||||
// add a channel to s_boundChans, throwing if a dup exists
|
||||
function s_addBoundChan(win, origin, scope, handler) {
|
||||
function hasWin(arr) {
|
||||
for (var i = 0; i < arr.length; i++) if (arr[i].win === win) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// does she exist?
|
||||
var exists = false;
|
||||
|
||||
|
||||
if (origin === '*') {
|
||||
// we must check all other origins, sadly.
|
||||
for (var k in s_boundChans) {
|
||||
if (!s_boundChans.hasOwnProperty(k)) continue;
|
||||
if (k === '*') continue;
|
||||
if (typeof s_boundChans[k][scope] === 'object') {
|
||||
exists = hasWin(s_boundChans[k][scope]);
|
||||
if (exists) break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// we must check only '*'
|
||||
if ((s_boundChans['*'] && s_boundChans['*'][scope])) {
|
||||
exists = hasWin(s_boundChans['*'][scope]);
|
||||
}
|
||||
if (!exists && s_boundChans[origin] && s_boundChans[origin][scope])
|
||||
{
|
||||
exists = hasWin(s_boundChans[origin][scope]);
|
||||
}
|
||||
}
|
||||
if (exists) throw "A channel is already bound to the same window which overlaps with origin '"+ origin +"' and has scope '"+scope+"'";
|
||||
|
||||
if (typeof s_boundChans[origin] != 'object') s_boundChans[origin] = { };
|
||||
if (typeof s_boundChans[origin][scope] != 'object') s_boundChans[origin][scope] = [ ];
|
||||
s_boundChans[origin][scope].push({win: win, handler: handler});
|
||||
}
|
||||
|
||||
function s_removeBoundChan(win, origin, scope) {
|
||||
var arr = s_boundChans[origin][scope];
|
||||
for (var i = 0; i < arr.length; i++) {
|
||||
if (arr[i].win === win) {
|
||||
arr.splice(i,1);
|
||||
}
|
||||
}
|
||||
if (s_boundChans[origin][scope].length === 0) {
|
||||
delete s_boundChans[origin][scope];
|
||||
}
|
||||
}
|
||||
|
||||
function s_isArray(obj) {
|
||||
if (Array.isArray) return Array.isArray(obj);
|
||||
else {
|
||||
return (obj.constructor.toString().indexOf("Array") != -1);
|
||||
}
|
||||
}
|
||||
|
||||
// No two outstanding outbound messages may have the same id, period. Given that, a single table
|
||||
// mapping "transaction ids" to message handlers, allows efficient routing of Callback, Error, and
|
||||
// Response messages. Entries are added to this table when requests are sent, and removed when
|
||||
// responses are received.
|
||||
var s_transIds = { };
|
||||
|
||||
// class singleton onMessage handler
|
||||
// this function is registered once and all incoming messages route through here. This
|
||||
// arrangement allows certain efficiencies, message data is only parsed once and dispatch
|
||||
// is more efficient, especially for large numbers of simultaneous channels.
|
||||
var s_onMessage = function(e) {
|
||||
try {
|
||||
var m = JSON.parse(e.data);
|
||||
if (typeof m !== 'object' || m === null) throw "malformed";
|
||||
} catch(e) {
|
||||
// just ignore any posted messages that do not consist of valid JSON
|
||||
return;
|
||||
}
|
||||
|
||||
var w = e.source;
|
||||
var o = e.origin;
|
||||
var s, i, meth;
|
||||
|
||||
if (typeof m.method === 'string') {
|
||||
var ar = m.method.split('::');
|
||||
if (ar.length == 2) {
|
||||
s = ar[0];
|
||||
meth = ar[1];
|
||||
} else {
|
||||
meth = m.method;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof m.id !== 'undefined') i = m.id;
|
||||
|
||||
// w is message source window
|
||||
// o is message origin
|
||||
// m is parsed message
|
||||
// s is message scope
|
||||
// i is message id (or undefined)
|
||||
// meth is unscoped method name
|
||||
// ^^ based on these factors we can route the message
|
||||
|
||||
// if it has a method it's either a notification or a request,
|
||||
// route using s_boundChans
|
||||
if (typeof meth === 'string') {
|
||||
var delivered = false;
|
||||
if (s_boundChans[o] && s_boundChans[o][s]) {
|
||||
for (var j = 0; j < s_boundChans[o][s].length; j++) {
|
||||
if (s_boundChans[o][s][j].win === w) {
|
||||
s_boundChans[o][s][j].handler(o, meth, m);
|
||||
delivered = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!delivered && s_boundChans['*'] && s_boundChans['*'][s]) {
|
||||
for (var j = 0; j < s_boundChans['*'][s].length; j++) {
|
||||
if (s_boundChans['*'][s][j].win === w) {
|
||||
s_boundChans['*'][s][j].handler(o, meth, m);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// otherwise it must have an id (or be poorly formed
|
||||
else if (typeof i != 'undefined') {
|
||||
if (s_transIds[i]) s_transIds[i](o, meth, m);
|
||||
}
|
||||
};
|
||||
|
||||
// Setup postMessage event listeners
|
||||
if (window.addEventListener) window.addEventListener('message', s_onMessage, false);
|
||||
else if(window.attachEvent) window.attachEvent('onmessage', s_onMessage);
|
||||
|
||||
/* a messaging channel is constructed from a window and an origin.
|
||||
* the channel will assert that all messages received over the
|
||||
* channel match the origin
|
||||
*
|
||||
* Arguments to Channel.build(cfg):
|
||||
*
|
||||
* cfg.window - the remote window with which we'll communicate
|
||||
* cfg.origin - the expected origin of the remote window, may be '*'
|
||||
* which matches any origin
|
||||
* cfg.scope - the 'scope' of messages. a scope string that is
|
||||
* prepended to message names. local and remote endpoints
|
||||
* of a single channel must agree upon scope. Scope may
|
||||
* not contain double colons ('::').
|
||||
* cfg.debugOutput - A boolean value. If true and window.console.log is
|
||||
* a function, then debug strings will be emitted to that
|
||||
* function.
|
||||
* cfg.debugOutput - A boolean value. If true and window.console.log is
|
||||
* a function, then debug strings will be emitted to that
|
||||
* function.
|
||||
* cfg.postMessageObserver - A function that will be passed two arguments,
|
||||
* an origin and a message. It will be passed these immediately
|
||||
* before messages are posted.
|
||||
* cfg.gotMessageObserver - A function that will be passed two arguments,
|
||||
* an origin and a message. It will be passed these arguments
|
||||
* immediately after they pass scope and origin checks, but before
|
||||
* they are processed.
|
||||
* cfg.onReady - A function that will be invoked when a channel becomes "ready",
|
||||
* this occurs once both sides of the channel have been
|
||||
* instantiated and an application level handshake is exchanged.
|
||||
* the onReady function will be passed a single argument which is
|
||||
* the channel object that was returned from build().
|
||||
*/
|
||||
return {
|
||||
build: function(cfg) {
|
||||
var debug = function(m) {
|
||||
if (cfg.debugOutput && window.console && window.console.log) {
|
||||
// try to stringify, if it doesn't work we'll let javascript's built in toString do its magic
|
||||
try { if (typeof m !== 'string') m = JSON.stringify(m); } catch(e) { }
|
||||
console.log("["+chanId+"] " + m);
|
||||
}
|
||||
};
|
||||
|
||||
/* browser capabilities check */
|
||||
if (!window.postMessage) throw("jschannel cannot run this browser, no postMessage");
|
||||
if (!window.JSON || !window.JSON.stringify || ! window.JSON.parse) {
|
||||
throw("jschannel cannot run this browser, no JSON parsing/serialization");
|
||||
}
|
||||
|
||||
/* basic argument validation */
|
||||
if (typeof cfg != 'object') throw("Channel build invoked without a proper object argument");
|
||||
|
||||
if (!cfg.window || !cfg.window.postMessage) throw("Channel.build() called without a valid window argument");
|
||||
|
||||
/* we'd have to do a little more work to be able to run multiple channels that intercommunicate the same
|
||||
* window... Not sure if we care to support that */
|
||||
if (window === cfg.window) throw("target window is same as present window -- not allowed");
|
||||
|
||||
// let's require that the client specify an origin. if we just assume '*' we'll be
|
||||
// propagating unsafe practices. that would be lame.
|
||||
var validOrigin = false;
|
||||
if (typeof cfg.origin === 'string') {
|
||||
var oMatch;
|
||||
if (cfg.origin === "*") validOrigin = true;
|
||||
// allow valid domains under http and https. Also, trim paths off otherwise valid origins.
|
||||
else if (null !== (oMatch = cfg.origin.match(/^https?:\/\/(?:[-a-zA-Z0-9_\.])+(?::\d+)?/))) {
|
||||
cfg.origin = oMatch[0].toLowerCase();
|
||||
validOrigin = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!validOrigin) throw ("Channel.build() called with an invalid origin");
|
||||
|
||||
if (typeof cfg.scope !== 'undefined') {
|
||||
if (typeof cfg.scope !== 'string') throw 'scope, when specified, must be a string';
|
||||
if (cfg.scope.split('::').length > 1) throw "scope may not contain double colons: '::'";
|
||||
}
|
||||
|
||||
/* private variables */
|
||||
// generate a random and psuedo unique id for this channel
|
||||
var chanId = (function () {
|
||||
var text = "";
|
||||
var alpha = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
for(var i=0; i < 5; i++) text += alpha.charAt(Math.floor(Math.random() * alpha.length));
|
||||
return text;
|
||||
})();
|
||||
|
||||
// registrations: mapping method names to call objects
|
||||
var regTbl = { };
|
||||
// current oustanding sent requests
|
||||
var outTbl = { };
|
||||
// current oustanding received requests
|
||||
var inTbl = { };
|
||||
// are we ready yet? when false we will block outbound messages.
|
||||
var ready = false;
|
||||
var pendingQueue = [ ];
|
||||
|
||||
var createTransaction = function(id,origin,callbacks) {
|
||||
var shouldDelayReturn = false;
|
||||
var completed = false;
|
||||
|
||||
return {
|
||||
origin: origin,
|
||||
invoke: function(cbName, v) {
|
||||
// verify in table
|
||||
if (!inTbl[id]) throw "attempting to invoke a callback of a nonexistent transaction: " + id;
|
||||
// verify that the callback name is valid
|
||||
var valid = false;
|
||||
for (var i = 0; i < callbacks.length; i++) if (cbName === callbacks[i]) { valid = true; break; }
|
||||
if (!valid) throw "request supports no such callback '" + cbName + "'";
|
||||
|
||||
// send callback invocation
|
||||
postMessage({ id: id, callback: cbName, params: v});
|
||||
},
|
||||
error: function(error, message) {
|
||||
completed = true;
|
||||
// verify in table
|
||||
if (!inTbl[id]) throw "error called for nonexistent message: " + id;
|
||||
|
||||
// remove transaction from table
|
||||
delete inTbl[id];
|
||||
|
||||
// send error
|
||||
postMessage({ id: id, error: error, message: message });
|
||||
},
|
||||
complete: function(v) {
|
||||
completed = true;
|
||||
// verify in table
|
||||
if (!inTbl[id]) throw "complete called for nonexistent message: " + id;
|
||||
// remove transaction from table
|
||||
delete inTbl[id];
|
||||
// send complete
|
||||
postMessage({ id: id, result: v });
|
||||
},
|
||||
delayReturn: function(delay) {
|
||||
if (typeof delay === 'boolean') {
|
||||
shouldDelayReturn = (delay === true);
|
||||
}
|
||||
return shouldDelayReturn;
|
||||
},
|
||||
completed: function() {
|
||||
return completed;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
var setTransactionTimeout = function(transId, timeout, method) {
|
||||
return window.setTimeout(function() {
|
||||
if (outTbl[transId]) {
|
||||
// XXX: what if client code raises an exception here?
|
||||
var msg = "timeout (" + timeout + "ms) exceeded on method '" + method + "'";
|
||||
(1,outTbl[transId].error)("timeout_error", msg);
|
||||
delete outTbl[transId];
|
||||
delete s_transIds[transId];
|
||||
}
|
||||
}, timeout);
|
||||
};
|
||||
|
||||
var onMessage = function(origin, method, m) {
|
||||
// if an observer was specified at allocation time, invoke it
|
||||
if (typeof cfg.gotMessageObserver === 'function') {
|
||||
// pass observer a clone of the object so that our
|
||||
// manipulations are not visible (i.e. method unscoping).
|
||||
// This is not particularly efficient, but then we expect
|
||||
// that message observers are primarily for debugging anyway.
|
||||
try {
|
||||
cfg.gotMessageObserver(origin, m);
|
||||
} catch (e) {
|
||||
debug("gotMessageObserver() raised an exception: " + e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// now, what type of message is this?
|
||||
if (m.id && method) {
|
||||
// a request! do we have a registered handler for this request?
|
||||
if (regTbl[method]) {
|
||||
var trans = createTransaction(m.id, origin, m.callbacks ? m.callbacks : [ ]);
|
||||
inTbl[m.id] = { };
|
||||
try {
|
||||
// callback handling. we'll magically create functions inside the parameter list for each
|
||||
// callback
|
||||
if (m.callbacks && s_isArray(m.callbacks) && m.callbacks.length > 0) {
|
||||
for (var i = 0; i < m.callbacks.length; i++) {
|
||||
var path = m.callbacks[i];
|
||||
var obj = m.params;
|
||||
var pathItems = path.split('/');
|
||||
for (var j = 0; j < pathItems.length - 1; j++) {
|
||||
var cp = pathItems[j];
|
||||
if (typeof obj[cp] !== 'object') obj[cp] = { };
|
||||
obj = obj[cp];
|
||||
}
|
||||
obj[pathItems[pathItems.length - 1]] = (function() {
|
||||
var cbName = path;
|
||||
return function(params) {
|
||||
return trans.invoke(cbName, params);
|
||||
};
|
||||
})();
|
||||
}
|
||||
}
|
||||
var resp = regTbl[method](trans, m.params);
|
||||
if (!trans.delayReturn() && !trans.completed()) trans.complete(resp);
|
||||
} catch(e) {
|
||||
// automagic handling of exceptions:
|
||||
var error = "runtime_error";
|
||||
var message = null;
|
||||
// * if it's a string then it gets an error code of 'runtime_error' and string is the message
|
||||
if (typeof e === 'string') {
|
||||
message = e;
|
||||
} else if (typeof e === 'object') {
|
||||
// either an array or an object
|
||||
// * if it's an array of length two, then array[0] is the code, array[1] is the error message
|
||||
if (e && s_isArray(e) && e.length == 2) {
|
||||
error = e[0];
|
||||
message = e[1];
|
||||
}
|
||||
// * if it's an object then we'll look form error and message parameters
|
||||
else if (typeof e.error === 'string') {
|
||||
error = e.error;
|
||||
if (!e.message) message = "";
|
||||
else if (typeof e.message === 'string') message = e.message;
|
||||
else e = e.message; // let the stringify/toString message give us a reasonable verbose error string
|
||||
}
|
||||
}
|
||||
|
||||
// message is *still* null, let's try harder
|
||||
if (message === null) {
|
||||
try {
|
||||
message = JSON.stringify(e);
|
||||
/* On MSIE8, this can result in 'out of memory', which
|
||||
* leaves message undefined. */
|
||||
if (typeof(message) == 'undefined')
|
||||
message = e.toString();
|
||||
} catch (e2) {
|
||||
message = e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
trans.error(error,message);
|
||||
}
|
||||
}
|
||||
} else if (m.id && m.callback) {
|
||||
if (!outTbl[m.id] ||!outTbl[m.id].callbacks || !outTbl[m.id].callbacks[m.callback])
|
||||
{
|
||||
debug("ignoring invalid callback, id:"+m.id+ " (" + m.callback +")");
|
||||
} else {
|
||||
// XXX: what if client code raises an exception here?
|
||||
outTbl[m.id].callbacks[m.callback](m.params);
|
||||
}
|
||||
} else if (m.id) {
|
||||
if (!outTbl[m.id]) {
|
||||
debug("ignoring invalid response: " + m.id);
|
||||
} else {
|
||||
// XXX: what if client code raises an exception here?
|
||||
if (m.error) {
|
||||
(1,outTbl[m.id].error)(m.error, m.message);
|
||||
} else {
|
||||
if (m.result !== undefined) (1,outTbl[m.id].success)(m.result);
|
||||
else (1,outTbl[m.id].success)();
|
||||
}
|
||||
delete outTbl[m.id];
|
||||
delete s_transIds[m.id];
|
||||
}
|
||||
} else if (method) {
|
||||
// tis a notification.
|
||||
if (regTbl[method]) {
|
||||
// yep, there's a handler for that.
|
||||
// transaction has only origin for notifications.
|
||||
regTbl[method]({ origin: origin }, m.params);
|
||||
// if the client throws, we'll just let it bubble out
|
||||
// what can we do? Also, here we'll ignore return values
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// now register our bound channel for msg routing
|
||||
s_addBoundChan(cfg.window, cfg.origin, ((typeof cfg.scope === 'string') ? cfg.scope : ''), onMessage);
|
||||
|
||||
// scope method names based on cfg.scope specified when the Channel was instantiated
|
||||
var scopeMethod = function(m) {
|
||||
if (typeof cfg.scope === 'string' && cfg.scope.length) m = [cfg.scope, m].join("::");
|
||||
return m;
|
||||
};
|
||||
|
||||
// a small wrapper around postmessage whose primary function is to handle the
|
||||
// case that clients start sending messages before the other end is "ready"
|
||||
var postMessage = function(msg, force) {
|
||||
if (!msg) throw "postMessage called with null message";
|
||||
|
||||
// delay posting if we're not ready yet.
|
||||
var verb = (ready ? "post " : "queue ");
|
||||
debug(verb + " message: " + JSON.stringify(msg));
|
||||
if (!force && !ready) {
|
||||
pendingQueue.push(msg);
|
||||
} else {
|
||||
if (typeof cfg.postMessageObserver === 'function') {
|
||||
try {
|
||||
cfg.postMessageObserver(cfg.origin, msg);
|
||||
} catch (e) {
|
||||
debug("postMessageObserver() raised an exception: " + e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
cfg.window.postMessage(JSON.stringify(msg), cfg.origin);
|
||||
}
|
||||
};
|
||||
|
||||
var onReady = function(trans, type) {
|
||||
debug('ready msg received');
|
||||
if (ready) throw "received ready message while in ready state. help!";
|
||||
|
||||
if (type === 'ping') {
|
||||
chanId += '-R';
|
||||
} else {
|
||||
chanId += '-L';
|
||||
}
|
||||
|
||||
obj.unbind('__ready'); // now this handler isn't needed any more.
|
||||
ready = true;
|
||||
debug('ready msg accepted.');
|
||||
|
||||
if (type === 'ping') {
|
||||
obj.notify({ method: '__ready', params: 'pong' });
|
||||
}
|
||||
|
||||
// flush queue
|
||||
while (pendingQueue.length) {
|
||||
postMessage(pendingQueue.pop());
|
||||
}
|
||||
|
||||
// invoke onReady observer if provided
|
||||
if (typeof cfg.onReady === 'function') cfg.onReady(obj);
|
||||
};
|
||||
|
||||
var obj = {
|
||||
// tries to unbind a bound message handler. returns false if not possible
|
||||
unbind: function (method) {
|
||||
if (regTbl[method]) {
|
||||
if (!(delete regTbl[method])) throw ("can't delete method: " + method);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
bind: function (method, cb) {
|
||||
if (!method || typeof method !== 'string') throw "'method' argument to bind must be string";
|
||||
if (!cb || typeof cb !== 'function') throw "callback missing from bind params";
|
||||
|
||||
if (regTbl[method]) throw "method '"+method+"' is already bound!";
|
||||
regTbl[method] = cb;
|
||||
return this;
|
||||
},
|
||||
call: function(m) {
|
||||
if (!m) throw 'missing arguments to call function';
|
||||
if (!m.method || typeof m.method !== 'string') throw "'method' argument to call must be string";
|
||||
if (!m.success || typeof m.success !== 'function') throw "'success' callback missing from call";
|
||||
|
||||
// now it's time to support the 'callback' feature of jschannel. We'll traverse the argument
|
||||
// object and pick out all of the functions that were passed as arguments.
|
||||
var callbacks = { };
|
||||
var callbackNames = [ ];
|
||||
var seen = [ ];
|
||||
|
||||
var pruneFunctions = function (path, obj) {
|
||||
if (seen.indexOf(obj) >= 0) {
|
||||
throw "params cannot be a recursive data structure"
|
||||
}
|
||||
seen.push(obj);
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
for (var k in obj) {
|
||||
if (!obj.hasOwnProperty(k)) continue;
|
||||
var np = path + (path.length ? '/' : '') + k;
|
||||
if (typeof obj[k] === 'function') {
|
||||
callbacks[np] = obj[k];
|
||||
callbackNames.push(np);
|
||||
delete obj[k];
|
||||
} else if (typeof obj[k] === 'object') {
|
||||
pruneFunctions(np, obj[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
pruneFunctions("", m.params);
|
||||
|
||||
// build a 'request' message and send it
|
||||
var msg = { id: s_curTranId, method: scopeMethod(m.method), params: m.params };
|
||||
if (callbackNames.length) msg.callbacks = callbackNames;
|
||||
|
||||
if (m.timeout)
|
||||
// XXX: This function returns a timeout ID, but we don't do anything with it.
|
||||
// We might want to keep track of it so we can cancel it using clearTimeout()
|
||||
// when the transaction completes.
|
||||
setTransactionTimeout(s_curTranId, m.timeout, scopeMethod(m.method));
|
||||
|
||||
// insert into the transaction table
|
||||
outTbl[s_curTranId] = { callbacks: callbacks, error: m.error, success: m.success };
|
||||
s_transIds[s_curTranId] = onMessage;
|
||||
|
||||
// increment current id
|
||||
s_curTranId++;
|
||||
|
||||
postMessage(msg);
|
||||
},
|
||||
notify: function(m) {
|
||||
if (!m) throw 'missing arguments to notify function';
|
||||
if (!m.method || typeof m.method !== 'string') throw "'method' argument to notify must be string";
|
||||
|
||||
// no need to go into any transaction table
|
||||
postMessage({ method: scopeMethod(m.method), params: m.params });
|
||||
},
|
||||
destroy: function () {
|
||||
s_removeBoundChan(cfg.window, cfg.origin, ((typeof cfg.scope === 'string') ? cfg.scope : ''));
|
||||
if (window.removeEventListener) window.removeEventListener('message', onMessage, false);
|
||||
else if(window.detachEvent) window.detachEvent('onmessage', onMessage);
|
||||
ready = false;
|
||||
regTbl = { };
|
||||
inTbl = { };
|
||||
outTbl = { };
|
||||
cfg.origin = null;
|
||||
pendingQueue = [ ];
|
||||
debug("channel destroyed");
|
||||
chanId = "";
|
||||
}
|
||||
};
|
||||
|
||||
obj.bind('__ready', onReady);
|
||||
setTimeout(function() {
|
||||
postMessage({ method: scopeMethod('__ready'), params: "ping" }, true);
|
||||
}, 0);
|
||||
|
||||
return obj;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -1,37 +1,28 @@
|
||||
(function (jsinput, undefined) {
|
||||
/*
|
||||
* JSChannel (https://github.com/mozilla/jschannel) will be loaded prior to this
|
||||
* script. We will use it use to let JSInput call 'gradeFn', and eventually
|
||||
* 'stateGetter' & 'stateSetter' in the iframe's content even if it hasn't the
|
||||
* same origin, therefore bypassing SOP:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Same_origin_policy_for_JavaScript
|
||||
*/
|
||||
|
||||
var JSInput = (function ($, undefined) {
|
||||
// Initialize js inputs on current page.
|
||||
// N.B.: No library assumptions about the iframe can be made (including,
|
||||
// most relevantly, jquery). Keep in mind what happens in which context
|
||||
// when modifying this file.
|
||||
|
||||
/* Check whether there is anything to be done */
|
||||
|
||||
// When all the problems are first loaded, we want to make sure the
|
||||
// constructor only runs once for each iframe; but we also want to make
|
||||
// sure that if part of the page is reloaded (e.g., a problem is
|
||||
// submitted), the constructor is called again.
|
||||
|
||||
if (!jsinput) {
|
||||
jsinput = {
|
||||
runs : 1,
|
||||
arr : [],
|
||||
exists : function(id) {
|
||||
jsinput.arr.filter(function(e, i, a) {
|
||||
return e.id = id;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
jsinput.runs++;
|
||||
|
||||
|
||||
/* Utils */
|
||||
|
||||
|
||||
// Take a string and find the nested object that corresponds to it. E.g.:
|
||||
// deepKey(obj, "an.example") -> obj["an"]["example"]
|
||||
var _deepKey = function(obj, path){
|
||||
// _deepKey(obj, "an.example") -> obj["an"]["example"]
|
||||
function _deepKey(obj, path){
|
||||
for (var i = 0, p=path.split('.'), len = p.length; i < len; i++){
|
||||
obj = obj[p[i]];
|
||||
}
|
||||
@@ -42,115 +33,141 @@
|
||||
/* END Utils */
|
||||
|
||||
|
||||
|
||||
|
||||
function jsinputConstructor(spec) {
|
||||
function jsinputConstructor(elem) {
|
||||
// Define an class that will be instantiated for each jsinput element
|
||||
// of the DOM
|
||||
|
||||
// 'that' is the object returned by the constructor. It has a single
|
||||
// public method, "update", which updates the hidden input field.
|
||||
var that = {};
|
||||
|
||||
/* Private methods */
|
||||
|
||||
var sect = $(spec.elem).parent().find('section[class="jsinput"]');
|
||||
var sectAttr = function (e) { return $(sect).attr(e); };
|
||||
var thisIFrame = $(spec.elem).
|
||||
find('iframe[name^="iframe_"]').
|
||||
get(0);
|
||||
var cWindow = thisIFrame.contentWindow;
|
||||
var section = $(elem).parent().find('section[class="jsinput"]'),
|
||||
sectionAttr = function (e) { return $(section).attr(e); },
|
||||
iframe = $(elem).find('iframe[name^="iframe_"]').get(0),
|
||||
cWindow = iframe.contentWindow,
|
||||
path = iframe.src.substring(0, iframe.src.lastIndexOf("/")+1),
|
||||
// Get the hidden input field to pass to customresponse
|
||||
inputField = $(elem).parent().find('input[id^="input_"]'),
|
||||
// Get the grade function name
|
||||
gradeFn = sectionAttr("data"),
|
||||
// Get state getter
|
||||
stateGetter = sectionAttr("data-getstate"),
|
||||
// Get state setter
|
||||
stateSetter = sectionAttr("data-setstate"),
|
||||
// Get stored state
|
||||
storedState = sectionAttr("data-stored"),
|
||||
// Bypass single-origin policy only if this attribute is "false"
|
||||
// In that case, use JSChannel to do so.
|
||||
sop = sectionAttr("data-sop"),
|
||||
channel;
|
||||
|
||||
sop = (sop !== "false");
|
||||
|
||||
// Get the hidden input field to pass to customresponse
|
||||
function _inputField() {
|
||||
var parent = $(spec.elem).parent();
|
||||
return parent.find('input[id^="input_"]');
|
||||
}
|
||||
var inputField = _inputField();
|
||||
|
||||
// Get the grade function name
|
||||
var getGradeFn = sectAttr("data");
|
||||
// Get state getter
|
||||
var getStateGetter = sectAttr("data-getstate");
|
||||
// Get state setter
|
||||
var getStateSetter = sectAttr("data-setstate");
|
||||
// Get stored state
|
||||
var getStoredState = sectAttr("data-stored");
|
||||
|
||||
|
||||
|
||||
// Put the return value of gradeFn in the hidden inputField.
|
||||
var update = function () {
|
||||
var ans;
|
||||
|
||||
ans = _deepKey(cWindow, gradeFn)();
|
||||
// setstate presumes getstate, so don't getstate unless setstate is
|
||||
// defined.
|
||||
if (getStateGetter && getStateSetter) {
|
||||
var state, store;
|
||||
state = unescape(_deepKey(cWindow, getStateGetter)());
|
||||
store = {
|
||||
answer: ans,
|
||||
state: state
|
||||
};
|
||||
inputField.val(JSON.stringify(store));
|
||||
} else {
|
||||
inputField.val(ans);
|
||||
}
|
||||
return;
|
||||
};
|
||||
if (!sop) {
|
||||
channel = Channel.build({
|
||||
window: cWindow,
|
||||
origin: path,
|
||||
scope: "JSInput"
|
||||
});
|
||||
}
|
||||
|
||||
/* Public methods */
|
||||
|
||||
// Only one public method that updates the hidden input field.
|
||||
var update = function (callback) {
|
||||
var answer, state, store;
|
||||
|
||||
that.update = update;
|
||||
|
||||
if (sop) {
|
||||
answer = _deepKey(cWindow, gradeFn)();
|
||||
// Setting state presumes getting state, so don't get state
|
||||
// unless set state is defined.
|
||||
if (stateGetter && stateSetter) {
|
||||
state = unescape(_deepKey(cWindow, stateGetter)());
|
||||
store = {
|
||||
answer: answer,
|
||||
state: state
|
||||
};
|
||||
inputField.val(JSON.stringify(store));
|
||||
} else {
|
||||
inputField.val(answer);
|
||||
}
|
||||
callback();
|
||||
} else {
|
||||
channel.call({
|
||||
method: "getGrade",
|
||||
params: "",
|
||||
success: function(val) {
|
||||
answer = decodeURI(val.toString());
|
||||
|
||||
// Setting state presumes getting state, so don't get
|
||||
// state unless set state is defined.
|
||||
if (stateGetter && stateSetter) {
|
||||
channel.call({
|
||||
method: "getState",
|
||||
params: "",
|
||||
success: function(val) {
|
||||
state = decodeURI(val.toString());
|
||||
store = {
|
||||
answer: answer,
|
||||
state: state
|
||||
};
|
||||
inputField.val(JSON.stringify(store));
|
||||
callback();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
inputField.val(answer);
|
||||
callback();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/* Initialization */
|
||||
|
||||
jsinput.arr.push(that);
|
||||
|
||||
// Put the update function as the value of the inputField's "waitfor"
|
||||
// attribute so that it is called when the check button is clicked.
|
||||
function bindCheck() {
|
||||
inputField.data('waitfor', that.update);
|
||||
return;
|
||||
}
|
||||
|
||||
var gradeFn = getGradeFn;
|
||||
|
||||
|
||||
bindCheck();
|
||||
inputField.data('waitfor', update);
|
||||
|
||||
// Check whether application takes in state and there is a saved
|
||||
// state to give it. If getStateSetter is specified but calling it
|
||||
// state to give it. If stateSetter is specified but calling it
|
||||
// fails, wait and try again, since the iframe might still be
|
||||
// loading.
|
||||
if (getStateSetter && getStoredState) {
|
||||
var sval, jsonVal;
|
||||
if (stateSetter && storedState) {
|
||||
var stateValue, jsonValue;
|
||||
|
||||
try {
|
||||
jsonVal = JSON.parse(getStoredState);
|
||||
jsonValue = JSON.parse(storedState);
|
||||
} catch (err) {
|
||||
jsonVal = getStoredState;
|
||||
jsonValue = storedState;
|
||||
}
|
||||
|
||||
if (typeof(jsonVal) === "object") {
|
||||
sval = jsonVal["state"];
|
||||
if (typeof(jsonValue) === "object") {
|
||||
stateValue = jsonValue["state"];
|
||||
} else {
|
||||
sval = jsonVal;
|
||||
stateValue = jsonValue;
|
||||
}
|
||||
|
||||
|
||||
// Try calling setstate every 200ms while it throws an exception,
|
||||
// up to five times; give up after that.
|
||||
// (Functions in the iframe may not be ready when we first try
|
||||
// calling it, but might just need more time. Give the functions
|
||||
// more time.)
|
||||
// 200 ms and 5 times are arbitrary but this has functioned with the
|
||||
// only application that has ever used JSInput, jsVGL. Something
|
||||
// more sturdy should be put in place.
|
||||
function whileloop(n) {
|
||||
if (n < 5){
|
||||
try {
|
||||
_deepKey(cWindow, getStateSetter)(sval);
|
||||
if (sop) {
|
||||
_deepKey(cWindow, stateSetter)(stateValue);
|
||||
} else {
|
||||
channel.call({
|
||||
method: "setState",
|
||||
params: stateValue,
|
||||
success: function() {
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setTimeout(whileloop(n+1), 200);
|
||||
}
|
||||
@@ -160,40 +177,44 @@
|
||||
}
|
||||
}
|
||||
whileloop(0);
|
||||
|
||||
}
|
||||
|
||||
|
||||
return that;
|
||||
}
|
||||
|
||||
|
||||
function walkDOM() {
|
||||
var newid;
|
||||
var dataProcessed, all;
|
||||
|
||||
// Find all jsinput elements, and create a jsinput object for each one
|
||||
var all = $(document).find('section[class="jsinput"]');
|
||||
// Find all jsinput elements
|
||||
allSections = $('section.jsinput');
|
||||
|
||||
all.each(function(index, value) {
|
||||
// Get just the mako variable 'id' from the id attribute
|
||||
newid = $(value).attr("id").replace(/^inputtype_/, "");
|
||||
|
||||
|
||||
if (!jsinput.exists(newid)){
|
||||
var newJsElem = jsinputConstructor({
|
||||
id: newid,
|
||||
elem: value,
|
||||
});
|
||||
// When a JSInput problem loads, its data-processed attribute is false,
|
||||
// so the jsconstructor will be called for it.
|
||||
// The constructor will not be called again on subsequent reruns of
|
||||
// this file by other JSInput. Only if it is reloaded, either with the
|
||||
// rest of the page or when it is submitted, will this constructor be
|
||||
// called again.
|
||||
allSections.each(function(index, value) {
|
||||
dataProcessed = ($(value).attr("data-processed") === "true");
|
||||
if (!dataProcessed) {
|
||||
jsinputConstructor(value);
|
||||
$(value).attr("data-processed", 'true');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// This is ugly, but without a timeout pages with multiple/heavy jsinputs
|
||||
// don't load properly.
|
||||
// 300 ms is arbitrary but this has functioned with the only application
|
||||
// that has ever used JSInput, jsVGL. Something more sturdy should be put in
|
||||
// place.
|
||||
if ($.isReady) {
|
||||
setTimeout(walkDOM, 300);
|
||||
} else {
|
||||
$(document).ready(setTimeout(walkDOM, 300));
|
||||
}
|
||||
|
||||
})(window.jsinput = window.jsinput || false);
|
||||
return {
|
||||
jsinputConstructor: jsinputConstructor,
|
||||
walkDOM: walkDOM
|
||||
};
|
||||
|
||||
})(window.jQuery);
|
||||
@@ -42,11 +42,13 @@ lib_paths:
|
||||
src_paths:
|
||||
- coffee/src
|
||||
- js/src
|
||||
- js/capa/src
|
||||
|
||||
# Paths to spec (test) JavaScript files
|
||||
spec_paths:
|
||||
- coffee/spec
|
||||
- js/spec
|
||||
- js/capa/spec
|
||||
|
||||
# Regular expressions used to exclude *.js files from
|
||||
# appearing in the test runner page.
|
||||
@@ -72,4 +74,5 @@ spec_paths:
|
||||
# plus the path to the file (relative to this YAML file)
|
||||
fixture_paths:
|
||||
- js/fixtures
|
||||
- js/capa/fixtures
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
##############################################################################
|
||||
JS Input
|
||||
##############################################################################
|
||||
|
||||
**NOTE**
|
||||
*Do not use this feature yet! Its attributes and behaviors may change
|
||||
without any concern for backwards compatibility. Moreover, it has only been
|
||||
tested in a very limited context. If you absolutely must, contact Julian
|
||||
(julian@edx.org). When the feature stabilizes, this note will be removed.*
|
||||
|
||||
This document explains how to write a JSInput input type. JSInput is meant to
|
||||
allow problem authors to easily turn working standalone HTML files into
|
||||
|
||||
Reference in New Issue
Block a user