diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a349cbb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.svn +node_modules diff --git a/.travis.yml b/.travis.yml index 35048b6..cf003a3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,4 @@ language: node_js node_js: - '0.10' +install: PHANTOMJS_CDNURL=http://cnpmjs.org/downloads npm install diff --git a/BUILD.txt b/BUILD.txt index 6208a91..c22f4af 100644 --- a/BUILD.txt +++ b/BUILD.txt @@ -29,5 +29,3 @@ Testing Unit tests use a headless webkit. When setting it up on a system without GUI, you may find that you need to install font support. On debian. "apt-get libfontconfig1" provides enough font support. - - diff --git a/Gruntfile.js b/Gruntfile.js index d05b587..12f17ea 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,3 +1,13 @@ +var serveNoDottedFiles = function(connect, options, middlewares) { + // Avoid leaking .git/.svn or other dotted files from test servers. + middlewares.unshift(function(req, res, next) { + if (req.url.indexOf('/.') < 0) { return next(); } + res.statusCode = 404 + res.end("Cannot GET " + req.url) + }); + return middlewares; +}; + module.exports = function(grunt) { "use strict"; @@ -21,7 +31,8 @@ module.exports = function(grunt) { connect: { testserver: { options: { - hostname: '0.0.0.0' + hostname: '0.0.0.0', + middleware: serveNoDottedFiles } } }, @@ -42,7 +53,8 @@ module.exports = function(grunt) { qunit: { all: ["test/*.html"], options: { - timeout: 100000 + timeout: 100000, + noGlobals: true } }, release: { @@ -66,6 +78,14 @@ module.exports = function(grunt) { grunt.loadNpmTasks('grunt-contrib-watch'); grunt.loadNpmTasks('grunt-release'); + grunt.task.registerTask('test', 'Run unit tests, or just one test.', + function(testname) { + if (!!testname) { + grunt.config('qunit.all', ['test/' + testname + '.html']); + } + grunt.task.run('qunit:all'); + }); + grunt.registerTask("testserver", ["watch:testserver"]); grunt.registerTask("default", ["uglify", "qunit"]); }; diff --git a/LICENSE.txt b/LICENSE.txt index 29597fe..f997494 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -2,7 +2,7 @@ jQuery-turtle version 2.0 LICENSE (MIT): -Copyright (c) 2013 David Bau +Copyright (c) 2013 Pencil Code Foundation, Google Inc., and other contributors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index a9c2369..fe5db8e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ jQuery-turtle ============= -version 2.0.8 +version 2.0.9 jQuery-turtle is a jQuery plugin for turtle graphics. @@ -254,7 +254,7 @@ the functions:
 eval $.turtle()  # Create the default turtle and global functions.
 
-defaultspeed Infinity
+speed Infinity
 write "Catch blue before red gets you."
 bk 100
 r = hatch red
@@ -264,7 +264,7 @@ tick 10, ->
   fd 6
   r.turnto turtle
   r.fd 4
-  b.turnto bearing b
+  b.turnto direction b
   b.fd 3
   if b.touches(turtle)
     write "You win!"
@@ -331,7 +331,7 @@ element.
 License (MIT)
 -------------
 
-Copyright (c) 2013 David Bau
+Copyright (c) 2014 Pencil Code, Google Inc., and other contributors
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/bower.json b/bower.json
index 43356a4..4eca85a 100644
--- a/bower.json
+++ b/bower.json
@@ -1,6 +1,8 @@
 {
   "name": "jquery-turtle",
-  "version": "2.0.8",
+  "main": "jquery-turtle.js",
+  "ignore": [],
+  "version": "2.0.9",
   "description": "Turtle graphics plugin for jQuery.",
   "devDependencies": {
     "jquery": "latest",
diff --git a/jquery-turtle.js b/jquery-turtle.js
index fec405d..9008141 100644
--- a/jquery-turtle.js
+++ b/jquery-turtle.js
@@ -4,11 +4,12 @@
 jQuery-turtle
 =============
 
-version 2.0.8
+version 2.0.9
 
 jQuery-turtle is a jQuery plugin for turtle graphics.
 
 With jQuery-turtle, every DOM element is a turtle that can be
+
 moved using turtle graphics methods like fd (forward), bk (back),
 rt (right turn), and lt (left turn).  The pen function allows
 a turtle to draw on a full-document canvas as it moves.
@@ -61,8 +62,8 @@ $(q).fd(100)      // Forward relative motion in local coordinates.
 $(q).bk(50)       // Back.
 $(q).rt(90)       // Right turn.  Optional second arg is turning radius.
 $(q).lt(45)       // Left turn.  Optional second arg is turning radius.
-$(q).slide(x, y)  // Slide right by x while sliding forward by y.
-$(q).jump(x, y)   // Like slide, but without drawing.
+$(q).slide(x, y)  // Move right by x while moving forward by y.
+$(q).leap(x, y)   // Like slide, but without drawing.
 $(q).moveto({pageX:x,pageY:y} | [x,y])  // Absolute motion on page.
 $(q).jumpto({pageX:x,pageY:y} | [x,y])  // Like moveto, without drawing.
 $(q).turnto(direction || position)      // Absolute direction adjustment.
@@ -102,7 +103,6 @@ $(q).nearest(pos) // Filters to item (or items if tied) nearest pos.
 $(q).within(d, t) // Filters to items with centers within d of t.pagexy().
 $(q).notwithin()  // The negation of within.
 $(q).cell(y, x)   // Selects the yth row and xth column cell in a table.
-$(q).hatch([n,] [img]) // Creates and returns n turtles with the given img.
 
@@ -124,9 +124,9 @@ Pen and Fill Styles ------------------- The turtle pen respects canvas styling: any valid strokeStyle is -accepted; and also using a space-separated syntax, lineWidth, lineCap, +accepted; and also using a css-like syntax, lineWidth, lineCap, lineJoin, miterLimit, and fillStyle can be specified, e.g., -pen('red lineWidth 5 lineCap square'). The same syntax applies for +pen('red;lineWidth:5;lineCap:square'). The same syntax applies for styling dot and fill (except that the default interpretation for the first value is fillStyle instead of strokeStyle). @@ -144,11 +144,11 @@ apostrophes, carets, underscores, digits, and slashes as in the standard. Enclosing letters in square brackets represents a chord, and z represents a rest. The default tempo is 120, but can be changed by passing a options object as the first parameter setting tempo, e.g., -{ tempo: 200 }. Other options include volume: 0.5, type: 'sine' or -'square' or 'sawtooth' or 'triangle', and envelope: which defines -an ADSR envelope e.g., { a: 0.01, d: 0.2, s: 0.1, r: 0.1 }. +{ tempo: 200 }. -The turtle's motion will pause while it is playing notes. +The turtle's motion will pause while it is playing notes. A single +tone can be played immediately (without participating in the +turtle animation queue) by using the "tone" method. Planning Logic in the Animation Queue ------------------------------------- @@ -217,6 +217,7 @@ $.turtle() are as follows:
 lastclick             // Event object of the last click event in the doc.
+lastdblclick          // The last double-click event.
 lastmousemove         // The last mousemove event.
 lastmouseup           // The last mouseup event.
 lastmousedown         // The last mousedown event.
@@ -232,6 +233,7 @@ timer(secs, fn)       // Calls back fn once after secs seconds.
 tick([perSec,] fn)    // Repeatedly calls fn at the given rate (null clears).
 done(fn)              // Calls back fn after all turtle animation is complete.
 random(n)             // Returns a random number [0..n-1].
+random(n,m)           // Returns a random number [n..m-1].
 random(list)          // Returns a random element of the list.
 random('normal')      // Returns a gaussian random (mean 0 stdev 1).
 random('uniform')     // Returns a uniform random [0...1).
@@ -241,10 +243,12 @@ random('gray')        // Returns a random hsl(0, 0, *) gray.
 remove()              // Removes default turtle and its globals (fd, etc).
 see(a, b, c...)       // Logs tree-expandable data into debugging panel.
 write(html)           // Appends html into the document body.
+type(plaintext)       // Appends preformatted text into a pre in the document.
 read([label,] fn)     // Makes a one-time input field, calls fn after entry.
 readnum([label,] fn)  // Like read, but restricted to numeric input.
 readstr([label,] fn)  // Like read, but never converts input to a number.
 button([label,] fn)   // Makes a clickable button, calls fn when clicked.
+menu(choices, fn)     // Makes a clickable choice, calls fn when chosen.
 table(m, n)           // Outputs a table with m rows and n columns.
 play('[DFG][EGc]')    // Plays musical notes.
 send(m, arg)          // Sends an async message to be received by recv(m, fn).
@@ -257,11 +261,11 @@ the functions:
 
 eval $.turtle()  # Create the default turtle and global functions.
 
-defaultspeed Infinity
+speed Infinity
 write "Catch blue before red gets you."
 bk 100
-r = hatch red
-b = hatch blue
+r = new Turtle red
+b = new Turtle blue
 tick 10, ->
   turnto lastmousemove
   fd 6
@@ -305,6 +309,8 @@ $(q).css('turtleTurningRadius, '50px');// arc turning radius for rotation.
 $(q).css('turtlePenStyle', 'red');     // or 'red lineWidth 2px' etc.
 $(q).css('turtlePenDown', 'up');       // default 'down' to draw with pen.
 $(q).css('turtleHull', '5 0 0 5 0 -5');// fine-tune shape for collisions.
+$(q).css('turtleTimbre', 'square');    // quality of the sound.
+$(q).css('turtleVolume', '0.3');       // volume of the sound.
 
Arbitrary 2d transforms are supported, including transforms of elements @@ -362,10 +368,28 @@ THE SOFTWARE. ////////////////////////////////////////////////////////////////////////// var undefined = void 0, + global = this, __hasProp = {}.hasOwnProperty, rootjQuery = jQuery(function() {}), - Pencil, Turtle, - global_plan_counter = 0; + interrupted = false, + async_pending = 0, + global_plan_depth = 0, + global_plan_queue = []; + +function nonrecursive_dequeue(elem, qname) { + if (global_plan_depth > 5) { + global_plan_queue.push({elem: elem, qname: qname}); + } else { + global_plan_depth += 1; + $.dequeue(elem, qname); + while (global_plan_queue.length > 0) { + var task = global_plan_queue.shift(); + $.dequeue(task.elem, task.qname); + checkForHungLoop() + } + global_plan_depth -= 1; + } +} function __extends(child, parent) { for (var key in parent) { @@ -417,6 +441,112 @@ if (!transform || !hasGetBoundingClientRect()) { return; } +// An options string looks like a (simplified) CSS properties string, +// of the form prop:value;prop:value; etc. If defaultProp is supplied +// then the string can begin with "value" (i.e., value1;prop:value2) +// and that first value will be interpreted as defaultProp:value1. +// Some rudimentary quoting can be done, e.g., value:"prop", etc. +function parseOptionString(str, defaultProp) { + if (typeof(str) != 'string') { + if (str == null) { + return {}; + } + if ($.isPlainObject(str)) { + return str; + } + str = '' + str; + } + // Each token is an identifier, a quoted or parenthesized string, + // a run of whitespace, or any other non-matching character. + var token = str.match(/[-a-zA-Z_][-\w]*|"[^"]*"|'[^']'|\([^()]*\)|\s+|./g), + result = {}, j, t, key = null, value, arg, + seencolon = false, vlist = [], firstval = true; + + // While parsing, commitvalue() validates and unquotes a prop:value + // pair and commits it to the result. + function commitvalue() { + // Trim whitespace + while (vlist.length && /^\s/.test(vlist[vlist.length - 1])) { vlist.pop(); } + while (vlist.length && /^\s/.test(vlist[0])) { vlist.shift(); } + if (vlist.length == 1 && ( + /^".*"$/.test(vlist[0]) || /^'.*'$/.test(vlist[0]))) { + // Unquote quoted string. + value = vlist[0].substr(1, vlist[0].length - 2); + } else if (vlist.length == 2 && vlist[0] == 'url' && + /^(.*)$/.test(vlist[1])) { + // Remove url(....) from around a string. + value = vlist[1].substr(1, vlist[1].length - 2); + } else { + // Form the string for the value. + arg = vlist.join(''); + // Convert the value to a number if it looks like a number. + if (arg == "") { + value = arg; + } else if (isNaN(arg)) { + value = arg; + } else { + value = Number(arg); + } + } + // Deal with a keyless first value. + if (!seencolon && firstval && defaultProp && vlist.length) { + // value will already have been formed. + key = defaultProp; + } + if (key) { + result[key] = value; + } + } + // Now the parsing: just iterate through all the tokens. + for (j = 0; j < token.length; ++j) { + t = token[j]; + if (!seencolon) { + // Before a colon, remember the first identifier as the key. + if (!key && /^[a-zA-Z_-]/.test(t)) { + key = t; + } + // And also look for the colon. + if (t == ':') { + seencolon = true; + vlist.length = 0; + continue; + } + } + if (t == ';') { + // When a semicolon is seen, form the value and save it. + commitvalue(); + // Then reset the parsing state. + key = null; + vlist.length = 0; + seencolon = false; + firstval = false; + continue; + } + // Accumulate all tokens into the vlist. + vlist.push(t); + } + commitvalue(); + return result; +} +// Prints a map of options as a parsable string. +// The inverse of parseOptionString. +function printOptionAsString(obj) { + var result = []; + function quoted(s) { + if (/[\s;]/.test(s)) { + if (s.indexOf('"') < 0) { + return '"' + s + '"'; + } + return "'" + s + "'"; + } + return s; + } + for (var k in obj) if (obj.hasOwnProperty(k)) { + result.push(k + ':' + quoted(obj[k]) + ';'); + } + return result.join(' '); +} + ////////////////////////////////////////////////////////////////////////// // MATH // 2d matrix support functions. @@ -474,6 +604,31 @@ function inverse2x2(a) { rotation(-(d[0])))); } +// By convention, a 2x3 transformation matrix has a 2x2 transform +// in the first four slots and a 1x2 translation in the last two slots. +// The array [a, b, c, d, e, f] is shorthand for the following 3x3 +// matrix where the upper-left 2x2 is an in-place transform, and the +// upper-right vector [e, f] is the translation. +// +// [a c e] The inverse of this 3x3 matrix can be formed by +// [b d f] figuring the inverse of the 2x2 upper-left corner +// [0 0 1] (call that Ai), and then negating the upper-right vector +// (call that -t) and then forming Ai * (-t). +// +// [ A t] (if Ai is the inverse of A) [ Ai Ai*(-t) ] +// [0 0 1] --- invert ---> [ 0 0 1 ] +// +// The result is of the same form, and can be represented as an array +// of six numbers. +function inverse2x3(a) { + var ai = inverse2x2(a); + if (a.length == 4) return ai; + var nait = matrixVectorProduct(ai, [-a[4], -a[5]]); + ai.push(nait[0]); + ai.push(nait[1]); + return ai; +} + function rotation(theta) { var c = Math.cos(theta), s = Math.sin(theta); @@ -598,8 +753,8 @@ function getElementTranslation(elem) { // Reads out the 2x3 transform matrix of the given element. function readTransformMatrix(elem) { - var ts = (window.getComputedStyle ? - window.getComputedStyle(elem)[transform] : + var ts = (global.getComputedStyle ? + global.getComputedStyle(elem)[transform] : $.css(elem, 'transform')); if (!ts || ts === 'none') { return null; @@ -616,8 +771,25 @@ function readTransformMatrix(elem) { // Reads out the css transformOrigin property, if present. function readTransformOrigin(elem, wh) { - var gcs = (window.getComputedStyle ? window.getComputedStyle(elem) : null), - origin = (gcs && gcs[transformOrigin] || $.css(elem, 'transformOrigin')); + var hidden = ($.css(elem, 'display') === 'none'), + swapout, old, name; + if (hidden) { + // IE GetComputedStyle doesn't give pixel values for transformOrigin + // unless the element is unhidden. + swapout = { position: "absolute", visibility: "hidden", display: "block" }; + old = {}; + for (name in swapout) { + old[name] = elem.style[name]; + elem.style[name] = swapout[name]; + } + } + var gcs = (global.getComputedStyle ? global.getComputedStyle(elem) : null); + if (hidden) { + for (name in swapout) { + elem.style[name] = old[name]; + } + } + var origin = (gcs && gcs[transformOrigin] || $.css(elem, 'transformOrigin')); if (origin && origin.indexOf('%') < 0) { return $.map(origin.split(' '), parseFloat); } @@ -775,46 +947,48 @@ function cleanedStyle(trans) { return result; } -function getTurtleOrigin(elem, inverseParent, corners) { +// Returns the turtle's origin (the absolute location of its pen and +// center of rotation when no transforms are applied) in page coordinates. +function getTurtleOrigin(elem, inverseParent, extra) { + var state = $.data(elem, 'turtleData'); + if (state && state.quickhomeorigin && state.down && state.style && !extra + && elem.classList && elem.classList.contains('turtle')) { + return state.quickhomeorigin; + } var hidden = ($.css(elem, 'display') === 'none'), swapout = hidden ? { position: "absolute", visibility: "hidden", display: "block" } : {}, substTransform = swapout[transform] = (inverseParent ? 'matrix(' + $.map(inverseParent, cssNum).join(', ') + ', 0, 0)' : 'none'), - old = {}, name, gbcr; + old = {}, name, gbcr, transformOrigin; for (name in swapout) { old[name] = elem.style[name]; elem.style[name] = swapout[name]; } gbcr = getPageGbcr(elem); + transformOrigin = readTransformOrigin(elem, [gbcr.width, gbcr.height]); for (name in swapout) { elem.style[name] = cleanedStyle(old[name]); } - if (corners) { - corners.gbcr = gbcr; + if (extra) { + extra.gbcr = gbcr; + extra.localorigin = transformOrigin; } - return addVector( - [gbcr.left, gbcr.top], - readTransformOrigin(elem, [gbcr.width, gbcr.height])); -} - -function unattached(elt) { - // Unattached if not part of a document. - while (elt) { - if (elt.nodeType === 9) return false; - elt = elt.parentNode; + var result = addVector([gbcr.left, gbcr.top], transformOrigin); + if (state && state.down && state.style) { + state.quickhomeorigin = result; } - return true; + return result; } function wh() { // Quirks-mode compatible window height. - return window.innerHeight || $(window).height(); + return global.innerHeight || $(global).height(); } function ww() { // Quirks-mode compatible window width. - return window.innerWidth || $(window).width(); + return global.innerWidth || $(global).width(); } function dh() { @@ -825,6 +999,10 @@ function dw() { return document.body ? $(document).width() : document.width; } +function invisible(elem) { + return elem.offsetHeight <= 0 && elem.offsetWidth <= 0; +} + function makeGbcrLTWH(left, top, width, height) { return { left: left, top: top, right: left + width, bottom: top + height, @@ -837,7 +1015,7 @@ function getPageGbcr(elem) { return makeGbcrLTWH(elem.pageX, elem.pageY, 0, 0); } else if ($.isWindow(elem)) { return makeGbcrLTWH( - $(window).scrollLeft(), $(window).scrollTop(), ww(), wh()); + $(global).scrollLeft(), $(global).scrollTop(), ww(), wh()); } else if (elem.nodeType === 9) { return makeGbcrLTWH(0, 0, dw(), dh()); } else if (!('getBoundingClientRect' in elem)) { @@ -880,23 +1058,11 @@ function polyMatchesGbcr(poly, gbcr) { function readPageGbcr() { var raw = this.getBoundingClientRect(); - if (raw.width === 0 && raw.height === 0 && - raw.top === 0 && raw.left === 0 && unattached(this)) { - // Prentend unattached images have a size. - return { - top: 0, - bottom: this.height || 0, - left: 0, - right: this.width || 0, - width: this.width || 0, - height: this.height || 0 - } - } return { - top: raw.top + window.pageYOffset, - bottom: raw.bottom + window.pageYOffset, - left: raw.left + window.pageXOffset, - right: raw.right + window.pageXOffset, + top: raw.top + global.pageYOffset, + bottom: raw.bottom + global.pageYOffset, + left: raw.left + global.pageXOffset, + right: raw.right + global.pageXOffset, width: raw.width, height: raw.height }; @@ -924,8 +1090,7 @@ function computeTargetAsTurtlePosition(elem, target, limit, localx, localy) { localTarget = matrixVectorProduct(inverseParent, subtractVector([target.pageX, target.pageY], origin)); if (localx || localy) { - var ts = readTurtleTransform(elem, true), - sy = ts ? ts.sy : 1; + var sy = elemOldScale(elem); localTarget[0] += localx * sy; localTarget[1] -= localy * sy; } @@ -953,7 +1118,7 @@ function computePositionAsLocalOffset(elem, home) { ts = readTurtleTransform(elem, true), localHome = inverseParent && matrixVectorProduct(inverseParent, subtractVector([home.pageX, home.pageY], origin)), - isy = ts && 1 / ts.sy; + isy = 1 / elemOldScale(elem); if (!inverseParent) { return; } return [(ts.tx - localHome[0]) * isy, (localHome[1] - ts.ty) * isy]; } @@ -962,11 +1127,12 @@ function convertLocalXyToPageCoordinates(elem, localxy) { var totalParentTransform = totalTransform2x2(elem.parentElement), ts = readTurtleTransform(elem, true), center = $(homeContainer(elem)).pagexy(), + sy = elemOldScale(elem), result = [], pageOffset, j; for (j = 0; j < localxy.length; j++) { pageOffset = matrixVectorProduct( - totalParentTransform, [localxy[j][0] * ts.sy, -localxy[j][1] * ts.sy]); + totalParentTransform, [localxy[j][0] * sy, -localxy[j][1] * sy]); result.push({ pageX: center.pageX + pageOffset[0], pageY: center.pageY + pageOffset[1] }); } @@ -981,12 +1147,12 @@ function convertLocalXyToPageCoordinates(elem, localxy) { function getCenterInPageCoordinates(elem) { if ($.isWindow(elem)) { return getRoundedCenterLTWH( - $(window).scrollLeft(), $(window).scrollTop(), ww(), wh()); + $(global).scrollLeft(), $(global).scrollTop(), ww(), wh()); } else if (elem.nodeType === 9 || elem == document.body) { return getRoundedCenterLTWH(0, 0, dw(), dh()); } var state = getTurtleData(elem); - if (state && state.quickpagexy && state.down) { + if (state && state.quickpagexy && state.down && state.style) { return state.quickpagexy; } var tr = getElementTranslation(elem), @@ -996,12 +1162,28 @@ function getCenterInPageCoordinates(elem) { origin = getTurtleOrigin(elem, inverseParent), pos = addVector(matrixVectorProduct(totalParentTransform, tr), origin), result = { pageX: pos[0], pageY: pos[1] }; - if (state && simple && state.down) { + if (state && simple && state.down && state.style && elem.classList && + elem.classList.contains('turtle')) { state.quickpagexy = result; } return result; } +// The quickpagexy variable is an optimization that assumes +// parent coordinates do not change. This function will clear +// the cache, and is used when we have a container that is moving. +function clearChildQuickLocations(elem) { + if (elem.tagName != 'CANVAS' && elem.tagName != 'IMG') { + $(elem).find('.turtle').each(function(j, e) { + var s = $.data(e, 'turtleData'); + if (s) { + s.quickpagexy = null; + s.quickhomeorigin = null; + } + }); + } +} + function polyToVectorsOffset(poly, offset) { if (!poly) { return null; } var result = [], j = 0; @@ -1016,7 +1198,7 @@ function polyToVectorsOffset(poly, offset) { function getCornersInPageCoordinates(elem, untransformed) { if ($.isWindow(elem)) { return getStraightRectLTWH( - $(window).scrollLeft(), $(window).scrollTop(), ww(), wh()); + $(global).scrollLeft(), $(global).scrollTop(), ww(), wh()); } else if (elem.nodeType === 9) { return getStraightRectLTWH(0, 0, dw(), dh()); } @@ -1025,7 +1207,7 @@ function getCornersInPageCoordinates(elem, untransformed) { totalTransform = matrixProduct(totalParentTransform, currentTransform), inverseParent = inverse2x2(totalParentTransform), out = {}, - origin = getTurtleOrigin(elem, inverseParent), + origin = getTurtleOrigin(elem, inverseParent, out), gbcr = out.gbcr, hull = polyToVectorsOffset(getTurtleData(elem).hull, origin) || [ [gbcr.left, gbcr.top], @@ -1050,7 +1232,7 @@ function getDirectionOnPage(elem) { r = convertToRadians(normalizeRotation(ts.rot)), ux = Math.sin(r), uy = Math.cos(r), totalParentTransform = totalTransform2x2(elem.parentElement), - up = matrixVectorProduct(totalParentTransform, [ux, uy]); + up = matrixVectorProduct(totalParentTransform, [ux, uy]), dp = Math.atan2(up[0], up[1]); return radiansToDegrees(dp); } @@ -1063,12 +1245,12 @@ function scrollWindowToDocumentPosition(pos, limit) { b = $('body'), dw = b.width(), dh = b.height(), - w = $(window); + w = $(global); if (tx > dw - ww2) { tx = dw - ww2; } if (tx < ww2) { tx = ww2; } if (ty > dh - wh2) { ty = dh - wh2; } if (ty < wh2) { ty = wh2; } - targ = { pageX: tx, pageY: ty }; + var targ = { pageX: tx, pageY: ty }; if ($.isNumeric(limit)) { targ = limitMovement(w.origin(), targ, limit); } @@ -1206,6 +1388,7 @@ function convexHull(points) { function parseTurtleHull(text) { if (!text) return null; + if ($.isArray(text)) return text; var nums = $.map(text.trim().split(/\s+/), parseFloat), points = [], j = 0; while (j + 1 < nums.length) { points.push({ pageX: nums[j], pageY: nums[j + 1] }); @@ -1226,6 +1409,7 @@ function writeTurtleHull(hull) { } function makeHullHook() { + // jQuery CSS hook for turtleHull property. return { get: function(elem, computed, extra) { var hull = getTurtleData(elem).hull; @@ -1289,7 +1473,7 @@ function readTurtleTransform(elem, computed) { function cssNum(n) { var r = n.toString(); - if (r.indexOf('e') >= 0) { + if (~r.indexOf('e')) { r = Number(n).toFixed(17); } return r; @@ -1320,6 +1504,8 @@ function writeTurtleTransform(ts) { return result.join(' '); } +function modulo(n, m) { return (+n % (m = +m) + m) % m; } + function radiansToDegrees(r) { var d = r * 180 / Math.PI; if (d > 180) { d -= 360; } @@ -1327,7 +1513,7 @@ function radiansToDegrees(r) { } function convertToRadians(d) { - return d * Math.PI / 180; + return d / 180 * Math.PI; } function normalizeRotation(x) { @@ -1353,28 +1539,30 @@ function normalizeRotationDelta(x) { ////////////////////////////////////////////////////////////////////////// // drawing state. -var drawing = { +var globalDrawing = { attached: false, surface: null, field: null, ctx: null, canvas: null, timer: null, + fieldMouse: false, // if a child of the field listens to mouse events. + fieldHook: false, // once the body-event-forwarding logic is set up. subpixel: 1 }; function getTurtleField() { - if (!drawing.field) { + if (!globalDrawing.field) { createSurfaceAndField(); } - return drawing.field; + return globalDrawing.field; } function getTurtleClipSurface() { - if (!drawing.surface) { + if (!globalDrawing.surface) { createSurfaceAndField(); } - return drawing.surface; + return globalDrawing.surface; } @@ -1388,88 +1576,110 @@ function createSurfaceAndField() { position: 'absolute', display: 'inline-block', top: 0, left: 0, width: '100%', height: '100%', - zIndex: -1, font: 'inherit', + // z-index: -1 is required to keep the turtle + // surface behind document text and buttons, so + // the canvas does not block interaction + zIndex: -1, // Setting transform origin for the turtle field // fixes a "center" point in page coordinates that // will not change even if the document resizes. transformOrigin: cw + "px " + ch + "px", + pointerEvents: 'none', overflow: 'hidden' - }); - $(field).attr('id', 'field') + }).addClass('turtlefield'); + $(field).attr('id', 'origin') .css({ position: 'absolute', display: 'inline-block', - top: ch, left: cw, width: '100%', height: '100%', + // Setting with to 100% allows label text to not wrap. + top: ch, left: cw, width: '100%', height: '0', font: 'inherit', // Setting transform origin for the turtle field // fixes a "center" point in page coordinates that // will not change even if the document resizes. transformOrigin: "0px 0px", + pointerEvents: 'all', + // Setting turtleSpeed to Infinity by default allows + // moving the origin instantly without sync. + turtleSpeed: Infinity }).appendTo(surface); - drawing.surface = surface; - drawing.field = field; + globalDrawing.surface = surface; + globalDrawing.field = field; attachClipSurface(); + // Now that we have a surface, the upward-center cartesian coordinate + // system based on that exists, so we can hook mouse events to add x, y. + addMouseEventHooks(); } function attachClipSurface() { if (document.body) { - $(drawing.surface).prependTo('body'); + if ($('html').attr('style') == null) { + // This prevents the body from shrinking. + $('html').css('min-height', '100%'); + } + $(globalDrawing.surface).prependTo('body'); // Attach an event handler to forward mouse events from the body // to turtles in the turtle field layer. - $('body').on('click.turtle ' + - 'mouseup.turtle mousedown.turtle mousemove.turtle', function(e) { - if (e.target === this && !e.isTrigger) { - // Only forward events directly on the body that (geometrically) - // touch a turtle directly within the turtlefield. - var warn = $.turtle.nowarn; - $.turtle.nowarn = true; - var sel = $(drawing.surface).find('.turtle').within('touch', e).eq(0); - $.turtle.nowarn = warn; - if (sel.length === 1) { - // Erase portions of the event that are wrong for the turtle. - e.target = null; - e.relatedTarget = null; - e.fromElement = null; - e.toElement = null; - sel.trigger(e); - return false; - } - } - }); + forwardBodyMouseEventsIfNeeded(); } else { $(document).ready(attachClipSurface); } } -function getTurtleDrawingCtx() { - if (drawing.ctx) { - return drawing.ctx; +// Given a $.data(elem, 'turtleData') state object, return or create +// the drawing canvas that this turtle should be drawing on. +function getDrawOnCanvas(state) { + if (!state.drawOnCanvas) { + state.drawOnCanvas = getTurtleDrawingCanvas(); + } + return state.drawOnCanvas; +} + +// Similar to getDrawOnCanvas, but for the read-only case: it avoids +// creating turtleData if none exists, and it avoid creating the global +// canvas if one doesn't already exist. If there is no global canvas, +// this returns null. +function getCanvasForReading(elem) { + var state = $.data(elem, 'turtleData'); + if (!state) return null; + if (state.drawOnCanvas) return state.drawOnCanvas; + return globalDrawing.canvas; +} + +function getTurtleDrawingCanvas() { + if (globalDrawing.canvas) { + return globalDrawing.canvas; } var surface = getTurtleClipSurface(); - drawing.canvas = document.createElement('canvas'); - $(drawing.canvas).css({'z-index': -1}); - surface.insertBefore(drawing.canvas, surface.firstChild); - drawing.ctx = drawing.canvas.getContext('2d'); + globalDrawing.canvas = document.createElement('canvas'); + $(globalDrawing.canvas).css({'z-index': -1}); + surface.insertBefore(globalDrawing.canvas, surface.firstChild); resizecanvas(); pollbodysize(resizecanvas); - $(window).resize(resizecanvas); - drawing.ctx.scale(drawing.subpixel, drawing.subpixel); - return drawing.ctx; + $(global).resize(resizecanvas); + return globalDrawing.canvas; } function getOffscreenCanvas(width, height) { - if (drawing.offscreen && - drawing.offscreen.width === width && - drawing.offscreen.height === height) { - return drawing.offscreen; - } - if (!drawing.offscreen) { - drawing.offscreen = document.createElement('canvas'); - } - drawing.offscreen.width = width; - drawing.offscreen.height = height; - return drawing.offscreen; + if (globalDrawing.offscreen && + globalDrawing.offscreen.width === width && + globalDrawing.offscreen.height === height) { + // Return a clean canvas. + globalDrawing.offscreen.getContext('2d').clearRect(0, 0, width, height); + return globalDrawing.offscreen; + } + if (!globalDrawing.offscreen) { + globalDrawing.offscreen = document.createElement('canvas'); + /* for debugging "touches": make offscreen canvas visisble. + $(globalDrawing.offscreen) + .css({position:'absolute',top:0,left:0,zIndex:1}) + .appendTo('body'); + */ + } + globalDrawing.offscreen.width = width; + globalDrawing.offscreen.height = height; + return globalDrawing.offscreen; } function pollbodysize(callback) { @@ -1483,40 +1693,65 @@ function pollbodysize(callback) { lastheight = b.height(); } }); - if (drawing.timer) { - clearInterval(drawing.timer); + if (globalDrawing.timer) { + clearInterval(globalDrawing.timer); } - drawing.timer = setInterval(poller, 250); + globalDrawing.timer = setInterval(poller, 250); +} + +function sizexy() { + // Notice that before the body exists, we cannot get its size; so + // we fall back to the window size. + // Using innerHeight || $(window).height() deals with quirks-mode. + var b = $('body'); + return [ + Math.max(b.outerWidth(true), global.innerWidth || $(global).width()), + Math.max(b.outerHeight(true), global.innerHeight || $(global).height()) + ]; } function resizecanvas() { - if (!drawing.canvas) return; - var b = $('body'), - wh = Math.max(b.outerHeight(true), - window.innerHeight || $(window).height()), - bw = Math.max(200, Math.ceil(b.outerWidth(true) / 100) * 100), - bh = Math.max(200, Math.ceil(wh / 100) * 100), - cw = drawing.canvas.width, - ch = drawing.canvas.height, + if (!globalDrawing.canvas) return; + var sxy = sizexy(), + ww = sxy[0], + wh = sxy[1], + cw = globalDrawing.canvas.width, + ch = globalDrawing.canvas.height, + // Logic: minimum size 200; only shrink if larger than 2000; + // and only resize if changed more than 100 pixels. + bw = Math.max(Math.min(2000, Math.max(200, cw)), + Math.ceil(ww / 100) * 100) * globalDrawing.subpixel, + bh = Math.max(Math.min(2000, Math.max(200, cw)), + Math.ceil(wh / 100) * 100) * globalDrawing.subpixel, tc; - $(drawing.surface).css({ width: b.outerWidth(true) + 'px', - height: wh + 'px'}); - if (cw != bw * drawing.subpixel || ch != bh * drawing.subpixel) { + $(globalDrawing.surface).css({ + width: ww + 'px', + height: wh + 'px' + }); + if (cw != bw || ch != bh) { // Transfer canvas out to tc and back again after resize. tc = document.createElement('canvas'); - tc.width = Math.min(cw, bw * drawing.subpixel); - tc.height = Math.min(ch, bh * drawing.subpixel); - tc.getContext('2d').drawImage(drawing.canvas, 0, 0); - drawing.canvas.width = bw * drawing.subpixel; - drawing.canvas.height = bh * drawing.subpixel; - drawing.canvas.getContext('2d').drawImage(tc, 0, 0); - $(drawing.canvas).css({ width: bw, height: bh }); + tc.width = Math.min(cw, bw); + tc.height = Math.min(ch, bh); + tc.getContext('2d').drawImage(globalDrawing.canvas, 0, 0); + globalDrawing.canvas.width = bw; + globalDrawing.canvas.height = bh; + globalDrawing.canvas.getContext('2d').drawImage(tc, 0, 0); + $(globalDrawing.canvas).css({ + width: bw / globalDrawing.subpixel, + height: bh / globalDrawing.subpixel + }); } } // turtlePenStyle style syntax function parsePenStyle(text, defaultProp) { if (!text) { return null; } + if (text && (typeof(text) == "function") && ( + text.helpname || text.name)) { + // Deal with "tan" and "fill". + text = (text.helpname || text.name); + } text = String(text); if (text.trim) { text = text.trim(); } if (!text || text === 'none') { return null; } @@ -1526,43 +1761,17 @@ function parsePenStyle(text, defaultProp) { var eraseMode = false; if (/^erase\b/.test(text)) { text = text.replace( - /^erase\b/, 'white globalCompositeOperation destination-out'); + /^erase\b/, 'white; globalCompositeOperation:destination-out'); eraseMode = true; } - var words = text.split(/\s+/), - mapping = { - strokeStyle: identity, - lineWidth: parseFloat, - lineCap: identity, - lineJoin: identity, - miterLimit: parseFloat, - fillStyle: identity, - globalCompositeOperation: identity - }, - result = {}, j, end = words.length; + var result = parseOptionString(text, defaultProp); if (eraseMode) { result.eraseMode = true; } - for (j = words.length - 1; j >= 0; --j) { - if (mapping.hasOwnProperty(words[j])) { - var key = words[j], - param = words.slice(j + 1, end).join(' '); - result[key] = mapping[key](param); - end = j; - } - } - if (end > 0 && !result[defaultProp]) { - result[defaultProp] = words.slice(0, end).join(' '); - } return result; } function writePenStyle(style) { if (!style) { return 'none'; } - var result = []; - $.each(style, function(k, v) { - result.push(k); - result.push(v); - }); - return result.join(' '); + return printOptionAsString(style); } function parsePenDown(style) { @@ -1580,12 +1789,18 @@ function getTurtleData(elem) { if (!state) { state = $.data(elem, 'turtleData', { style: null, + corners: [[]], path: [[]], - down: true, + down: false, speed: 'turtle', easing: 'swing', turningRadius: 0, - quickpagexy: null + drawOnCanvas: null, + quickpagexy: null, + quickhomeorigin: null, + oldscale: 1, + instrument: null, + stream: null }); } return state; @@ -1629,10 +1844,16 @@ function makePenStyleHook() { return writePenStyle(getTurtleData(elem).style); }, set: function(elem, value) { - var style = parsePenStyle(value, 'strokeStyle'); - getTurtleData(elem).style = style; + var style = parsePenStyle(value, 'strokeStyle'), + state = getTurtleData(elem); + if (state.style) { + // Switch to an empty pen first, to terminate paths. + state.style = null; + flushPenState(elem, state, true); + } + state.style = style; elem.style.turtlePenStyle = writePenStyle(style); - flushPenState(elem); + flushPenState(elem, state, true); } }; } @@ -1649,8 +1870,9 @@ function makePenDownHook() { if (style != state.down) { state.down = style; state.quickpagexy = null; + state.quickhomeorigin = null; elem.style.turtlePenDown = writePenDown(style); - flushPenState(elem); + flushPenState(elem, state, true); } } }; @@ -1661,6 +1883,11 @@ function isPointNearby(a, b) { Math.round(a.pageY - b.pageY) === 0; } +function isPointVeryNearby(a, b) { + return Math.round(1000 * (a.pageX - b.pageX)) === 0 && + Math.round(1000 * (a.pageY - b.pageY)) === 0; +} + function isBezierTiny(a, b) { return isPointNearby(a, b) && Math.round(a.pageX - b.pageX1) === 0 && @@ -1681,13 +1908,18 @@ function applyPenStyle(ctx, ps, scale) { scale = scale || 1; var extraWidth = ps.eraseMode ? 1 : 0; if (!ps || !('strokeStyle' in ps)) { ctx.strokeStyle = 'black'; } - if (!ps || !('lineWidth' in ps)) { ctx.lineWidth = 1.62 * scale + extraWidth; } + if (!ps || !('lineWidth' in ps)) { + ctx.lineWidth = 1.62 * scale + extraWidth; + } if (!ps || !('lineCap' in ps)) { ctx.lineCap = 'round'; } + if (!ps || !('lineJoin' in ps)) { ctx.lineJoin = 'round'; } if (ps) { for (var a in ps) { if (a === 'savePath' || a === 'eraseMode') { continue; } if (scale && a === 'lineWidth') { ctx[a] = scale * ps[a] + extraWidth; + } else if (a === 'lineDash') { + ctx.setLineDash(('' + ps[a]).split(/[,\s]/g)); } else { ctx[a] = ps[a]; } @@ -1695,22 +1927,78 @@ function applyPenStyle(ctx, ps, scale) { } } -function drawAndClearPath(path, style, scale) { - var ctx = getTurtleDrawingCtx(), +// Computes a matrix that transforms page coordinates to the local +// canvas coordinates. Applying this matrix as the canvas transform +// allows us to draw on a canvas using page coordinates; and the bits +// will show up on the canvas in the corresponding location on the +// physical page, even if the canvas has been moved by absolute +// position and CSS 2d transforms. +function computeCanvasPageTransform(canvas) { + if (!canvas) { return; } + if (canvas === globalDrawing.canvas) { + return [globalDrawing.subpixel, 0, 0, globalDrawing.subpixel]; + } + var totalParentTransform = totalTransform2x2(canvas.parentElement), + inverseParent = inverse2x2(totalParentTransform), + out = {}, + origin = getTurtleOrigin(canvas, inverseParent, out), + gbcr = out.gbcr, + originTranslate = [1, 0, 0, 1, -origin[0], -origin[1]], + finalScale = gbcr.width && gbcr.height && + [canvas.width / gbcr.width, 0, 0, canvas.height / gbcr.height], + localTransform = readTransformMatrix(canvas) || [1, 0, 0, 1], + inverseTransform = inverse2x3(localTransform), + totalInverse; + if (!inverseParent || !inverseTransform || !finalScale) { + return; + } + totalInverse = + matrixProduct( + matrixProduct( + matrixProduct( + finalScale, + inverseTransform), + inverseParent), + originTranslate); + totalInverse[4] += out.localorigin[0] * finalScale[0]; + totalInverse[5] += out.localorigin[1] * finalScale[3]; + return totalInverse; +} + +function setCanvasPageTransform(ctx, canvas) { + if (canvas === globalDrawing.canvas) { + ctx.setTransform( + globalDrawing.subpixel, 0, 0, globalDrawing.subpixel, 0, 0); + } else { + var pageToCanvas = computeCanvasPageTransform(canvas); + if (pageToCanvas) { + ctx.setTransform.apply(ctx, pageToCanvas); + } + } +} + +var buttOverlap = 0.67; + +function drawAndClearPath(drawOnCanvas, path, style, scale, truncateTo) { + var ctx = drawOnCanvas.getContext('2d'), isClosed, skipLast, j = path.length, segment; ctx.save(); + setCanvasPageTransform(ctx, drawOnCanvas); ctx.beginPath(); // Scale up lineWidth by sx. (TODO: consider parent transforms.) applyPenStyle(ctx, style, scale); while (j--) { if (path[j].length > 1) { segment = path[j]; - isClosed = segment.length > 2 && isPointNearby( - segment[0], segment[segment.length - 1]); + isClosed = segment.length > 2 && + isPointNearby(segment[0], segment[segment.length - 1]) && + !isPointNearby(segment[0], segment[Math.floor(segment.length / 2)]); skipLast = isClosed && (!('pageX2' in segment[segment.length - 1])); - ctx.moveTo(segment[0].pageX, segment[0].pageY); + var startx = segment[0].pageX; + var starty = segment[0].pageY; + ctx.moveTo(startx, starty); for (var k = 1; k < segment.length - (skipLast ? 1 : 0); ++k) { if ('pageX2' in segment[k] && !isBezierTiny(segment[k - 1], segment[k])) { @@ -1729,7 +2017,7 @@ function drawAndClearPath(path, style, scale) { if ('strokeStyle' in style) { ctx.stroke(); } ctx.restore(); path.length = 1; - path[0].splice(0, path[0].length - 1); + path[0].splice(0, Math.max(0, path[0].length - truncateTo)); } function addBezierToPath(path, start, triples) { @@ -1744,102 +2032,105 @@ function addBezierToPath(path, start, triples) { } } -function flushPenState(elem) { - var state = getTurtleData(elem); - if (!state.style || (!state.down && !state.style.savePath)) { - if (state.path.length > 1) { state.path.length = 1; } - if (state.path[0].length) { state.path[0].length = 0; } +function addToPathList(pathList, point) { + if (pathList.length && + (point.corner ? isPointVeryNearby(point, pathList[pathList.length - 1]) : + isPointNearby(point, pathList[pathList.length - 1]))) { + return; + } + pathList.push(point); +} + +function flushPenState(elem, state, corner) { + clearChildQuickLocations(elem); + if (!state) { + // Default is no pen and no path, so nothing to do. return; } - if (!state.down) { - // Penup when saving path will start a new segment if one isn't started. - if (state.path.length && state.path[0].length) { - state.path.shift([]); + var path = state.path, + style = state.style, + corners = state.corners; + if (!style || !state.down) { + // pen up or pen null will clear the tracing path. + if (path.length > 1) { path.length = 1; } + if (path[0].length) { path[0].length = 0; } + if (corner) { + if (!style) { + // pen null will clear the retracing path too. + if (corners.length > 1) corners.length = 1; + if (corners[0].length) corners[0].length = 0; + } else { + // pen up with a non-null pen will start a new discontinuous segment. + if (corners.length && corners[0].length) { + if (corners[0].length == 1) { + corners[0].length = 0; + } else { + corners.unshift([]); + } + } + } } return; } + if (!corner && style.savePath) return; + // Accumulate retracing path using only corners. var center = getCenterInPageCoordinates(elem); - if (!state.path[0].length || - !isPointNearby(center, state.path[0][state.path[0].length - 1])) { - state.path[0].push(center); - } - if (!state.style.savePath) { - var ts = readTurtleTransform(elem, true); - drawAndClearPath(state.path, state.style, ts.sx); + if (corner) { + center.corner = true; + addToPathList(corners[0], center); } + if (style.savePath) return; + // Add to tracing path, and trace it right away. + addToPathList(path[0], center); + var scale = drawingScale(elem); + // Last argument 2 means that the last two points are saved, which + // allows us to draw corner miters and also avoid 'butt' lineCap gaps. + drawAndClearPath(getDrawOnCanvas(state), state.path, style, scale, 2); } function endAndFillPenPath(elem, style) { - var ts = readTurtleTransform(elem, true), - state = getTurtleData(elem); - drawAndClearPath(state.path, style); - if (state.style && state.style.savePath) { - $.style(elem, 'turtlePenStyle', 'none'); - } -} - -function fillDot(position, diameter, style) { - var ctx = getTurtleDrawingCtx(); - ctx.save(); - applyPenStyle(ctx, style); - if (diameter === Infinity && drawing.canvas) { - ctx.fillRect(0, 0, drawing.canvas.width, drawing.canvas.height); - } else { - ctx.beginPath(); - ctx.arc(position.pageX, position.pageY, diameter / 2, 0, 2*Math.PI, false); - ctx.closePath(); - ctx.fill(); - if (style.strokeStyle) { - ctx.stroke(); - } + var state = getTurtleData(elem); + if (state.style) { + // Apply a default style. + style = $.extend({}, state.style, style); } - ctx.restore(); + var scale = drawingScale(elem); + drawAndClearPath(getDrawOnCanvas(state), state.corners, style, scale, 1); } function clearField(arg) { - if (!arg || /\bcanvas\b/.test(arg)) { - eraseBox(document, {fillStyle: 'transparent'}); + if ((!arg || /\bcanvas\b/.test(arg)) && globalDrawing.canvas) { + var ctx = globalDrawing.canvas.getContext('2d'); + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect( + 0, 0, globalDrawing.canvas.width, globalDrawing.canvas.height); + ctx.restore(); } if (!arg || /\bturtles\b/.test(arg)) { - if (drawing.surface) { - var sel = $(drawing.surface).find('.turtle'); + if (globalDrawing.surface) { + var sel = $(globalDrawing.surface).find('.turtle').not('.turtlefield'); if (global_turtle) { sel = sel.not(global_turtle); } sel.remove(); } } - if (!arg || /\btext\b/.test(arg)) { - var keep = $('samp#_testpanel'); - if (drawing.surface) { - keep = keep.add(drawing.surface); + if (!arg || /\blabels\b/.test(arg)) { + if (globalDrawing.surface) { + var sel = $(globalDrawing.surface).find('.turtlelabel') + .not('.turtlefield'); + sel.remove(); } - $('body').contents().not(keep).remove(); } -} - -function eraseBox(elem, style) { - var c = getCornersInPageCoordinates(elem), - ctx = getTurtleDrawingCtx(), - j = 1; - if (!c || c.length < 3) { return; } - ctx.save(); - // Clip to box and use 'copy' mode so that 'transparent' can be - // written into the canvas - that's better erasing than 'white'. - ctx.globalCompositeOperation = 'copy'; - applyPenStyle(ctx, style); - ctx.beginPath(); - ctx.moveTo(c[0].pageX, c[0].pageY); - for (; j < c.length; j += 1) { - ctx.lineTo(c[j].pageX, c[j].pageY); + if (!arg || /\btext\b/.test(arg)) { + // "turtlefield" is a CSS class to use to mark elements that + // should not be deleted by clearscreen. + $('body').contents().not('.turtlefield').remove(); } - ctx.closePath(); - ctx.clip(); - ctx.fill(); - ctx.restore(); } -function getBoundingBoxOfCorners(c, clipToDoc) { +function getBoundingBoxOfCorners(c, clip) { if (!c || c.length < 1) return null; var j = 1, result = { left: Math.floor(c[0].pageX), @@ -1853,30 +2144,44 @@ function getBoundingBoxOfCorners(c, clipToDoc) { result.right = Math.max(result.right, Math.ceil(c[j].pageX)); result.bottom = Math.max(result.bottom, Math.ceil(c[j].pageY)); } - if (clipToDoc) { - result.left = Math.max(0, result.left); - result.top = Math.max(0, result.top); - result.right = Math.min(dw(), result.right); - result.bottom = Math.min(dh(), result.bottom); + if (clip) { + result.left = Math.max(clip.left, result.left); + result.top = Math.max(clip.top, result.top); + result.right = Math.min(clip.right, result.right); + result.bottom = Math.min(clip.bottom, result.bottom); + } + return result; +} + +function transformPoints(m, points) { + if (!m || !points) return null; + if (m.length == 4 && isone2x2(m)) return points; + var result = [], j, prod; + for (j = 0; j < points.length; ++j) { + prod = matrixVectorProduct(m, [points[j].pageX, points[j].pageY]); + result.push({pageX: prod[0], pageY: prod[1]}); } return result; } function touchesPixel(elem, color) { - if (!elem || !drawing.canvas) { return false; } - var c = getCornersInPageCoordinates(elem), - canvas = drawing.canvas, - bb = getBoundingBoxOfCorners(c, true), - w = (bb.right - bb.left) * drawing.subpixel, - h = (bb.bottom - bb.top) * drawing.subpixel, + if (!elem) { return false; } + var rgba = rgbaForColor(color), + canvas = getCanvasForReading(elem); + if (!canvas) { return rgba && rgba[3] == 0; } + var trans = computeCanvasPageTransform(canvas), + originalc = getCornersInPageCoordinates(elem), + c = transformPoints(trans, originalc), + bb = getBoundingBoxOfCorners(c, + {left:0, top:0, right:canvas.width, bottom:canvas.height}), + w = (bb.right - bb.left), + h = (bb.bottom - bb.top), osc = getOffscreenCanvas(w, h), octx = osc.getContext('2d'), - rgba = rgbaForColor(color), - j = 1, k, data; + j = 1, k; if (!c || c.length < 3 || !w || !h) { return false; } - octx.clearRect(0, 0, w, h); octx.drawImage(canvas, - bb.left * drawing.subpixel, bb.top * drawing.subpixel, w, h, 0, 0, w, h); + bb.left, bb.top, w, h, 0, 0, w, h); octx.save(); // Erase everything outside clipping region. octx.beginPath(); @@ -1885,21 +2190,28 @@ function touchesPixel(elem, color) { octx.lineTo(w, h); octx.lineTo(0, h); octx.closePath(); - octx.moveTo((c[0].pageX - bb.left) * drawing.subpixel, - (c[0].pageY - bb.top) * drawing.subpixel); + octx.moveTo((c[0].pageX - bb.left), + (c[0].pageY - bb.top)); for (; j < c.length; j += 1) { - octx.lineTo((c[j].pageX - bb.left) * drawing.subpixel, - (c[j].pageY - bb.top) * drawing.subpixel); + octx.lineTo((c[j].pageX - bb.left), + (c[j].pageY - bb.top)); } octx.closePath(); octx.clip(); - octx.clearRect(0, 0, w, h); + if (rgba && rgba[3] == 0) { + // If testing for transparent, should clip with black, not transparent. + octx.fillRect(0, 0, w, h); + } else { + octx.clearRect(0, 0, w, h); + } octx.restore(); // Now examine the results and look for alpha > 0%. - data = octx.getImageData(0, 0, w, h).data; - if (!rgba) { + var data = octx.getImageData(0, 0, w, h).data; + if (!rgba || rgba[3] == 0) { + // Handle the "looking for any color" and "transparent" cases. + var wantcolor = !rgba; for (j = 0; j < data.length; j += 4) { - if (data[j + 3] > 0) return true; + if ((data[j + 3] > 0) == wantcolor) return true; } } else { for (j = 0; j < data.length; j += 4) { @@ -1921,9 +2233,14 @@ function touchesPixel(elem, color) { // Functions in direct support of exported methods. ////////////////////////////////////////////////////////////////////////// -function applyImg(sel, img) { - if (sel[0].tagName == 'IMG') { - setImageWithStableOrigin(sel[0], img.url, img.css); +function applyImg(sel, img, cb) { + if (img.img) { + if (sel[0].tagName == 'CANVAS' || sel[0].tagName == img.img.tagName) { + applyLoadedImage(img.img, sel[0], img.css); + } + } else if (sel[0].tagName == 'IMG' || sel[0].tagName == 'CANVAS') { + setImageWithStableOrigin(sel[0], img.url, img.css, cb); + cb = null; } else { var props = { backgroundImage: 'url(' + img.url + ')', @@ -1935,13 +2252,17 @@ function applyImg(sel, img) { } sel.css(props); } + if (cb) { + cb(); + } } function doQuickMove(elem, distance, sideways) { var ts = readTurtleTransform(elem, true), r = ts && convertToRadians(ts.rot), - scaledDistance = ts && (distance * ts.sy), - scaledSideways = ts && ((sideways || 0) * ts.sy), + sy = elemOldScale(elem), + scaledDistance = ts && (distance * sy), + scaledSideways = ts && ((sideways || 0) * sy), dy = -Math.cos(r) * scaledDistance, dx = Math.sin(r) * scaledDistance, state = $.data(elem, 'turtleData'), @@ -1960,7 +2281,7 @@ function doQuickMove(elem, distance, sideways) { ts.tx += dx; ts.ty += dy; elem.style[transform] = writeTurtleTransform(ts); - flushPenState(elem); + flushPenState(elem, state, true); } function doQuickMoveXY(elem, dx, dy) { @@ -1977,7 +2298,7 @@ function doQuickMoveXY(elem, dx, dy) { ts.tx += dx; ts.ty -= dy; elem.style[transform] = writeTurtleTransform(ts); - flushPenState(elem); + flushPenState(elem, state, true); } function doQuickRotate(elem, degrees) { @@ -1988,13 +2309,14 @@ function doQuickRotate(elem, degrees) { } function displacedPosition(elem, distance, sideways) { - var ts = readTurtleTransform(elem, true), - r = ts && convertToRadians(ts.rot), - scaledDistance = ts && (distance * ts.sy), - scaledSideways = ts && ((sideways || 0) * ts.sy), + var ts = readTurtleTransform(elem, true); + if (!ts) { return; } + var s = elemOldScale(elem), + r = convertToRadians(ts.rot), + scaledDistance = distance * s, + scaledSideways = (sideways || 0) * s, dy = -Math.cos(r) * scaledDistance, dx = Math.sin(r) * scaledDistance; - if (!ts) { return; } if (scaledSideways) { dy += Math.sin(r) * scaledSideways; dx += Math.cos(r) * scaledSideways; @@ -2035,12 +2357,14 @@ function makeTurtleEasingHook() { } } -function animTime(elem) { +function animTime(elem, intick) { var state = $.data(elem, 'turtleData'); - if (!state) return 'turtle'; + intick = intick || insidetick; + if (!state) return (intick ? 0 : 'turtle'); if ($.isNumeric(state.speed) || state.speed == 'Infinity') { return 1000 / state.speed; } + if (state.speed == 'turtle' && intick) return 0; return state.speed; } @@ -2063,16 +2387,18 @@ function makeTurtleForwardHook() { if (ts) { var r = convertToRadians(ts.rot), c = Math.cos(r), - s = Math.sin(r); + s = Math.sin(r), + sy = elemOldScale(elem); return cssNum(((ts.tx + middle[0]) * s - (ts.ty + middle[1]) * c) - / ts.sy) + 'px'; + / sy) + 'px'; } }, set: function(elem, value) { var ts = readTurtleTransform(elem, true) || {tx: 0, ty: 0, rot: 0, sx: 1, sy: 1, twi: 0}, middle = readTransformOrigin(elem), - v = parseFloat(value) * ts.sy, + sy = elemOldScale(elem), + v = parseFloat(value) * sy, r = convertToRadians(ts.rot), c = Math.cos(r), s = Math.sin(r), @@ -2090,7 +2416,7 @@ function makeTurtleForwardHook() { ts.tx = ntx; ts.ty = nty; elem.style[transform] = writeTurtleTransform(ts); - flushPenState(elem); + flushPenState(elem, state); } }; } @@ -2117,82 +2443,129 @@ function makeTurtleHook(prop, normalize, unit, displace) { pageY: qpxy.pageY + (ts.ty - oty) }; } - flushPenState(elem); + flushPenState(elem, state); + } else { + clearChildQuickLocations(elem); } } }; } +// Given a starting direction, angle change, and turning radius, +// this computes the side-radius (with a sign flip indicating +// the other side), the coordinates of the center dc, and the dx/dy +// displacement of the final location after the arc. +function setupArc( + r0, // starting direction in local coordinates + r1, // ending direction local coordinates + turnradius // turning radius in local coordinates +) { + var delta = normalizeRotationDelta(r1 - r0), + sradius = delta > 0 ? turnradius : -turnradius, + r0r = convertToRadians(r0), + dc = [Math.cos(r0r) * sradius, Math.sin(r0r) * sradius], + r1r = convertToRadians(r1); + return { + delta: delta, + sradius: sradius, + dc: dc, + dx: dc[0] - Math.cos(r1r) * sradius, + dy: dc[1] - Math.sin(r1r) * sradius + }; +} + +// Given a path array, a pageX/pageY starting position, +// arc information in local coordinates, and a 2d transform +// between page and local coordinates, this function adds to +// the path scaled page-coorindate beziers following the arc. +function addArcBezierPaths( + path, // path to add on to in page coordinates + start, // starting location in page coordinates + r0, // starting direction in local coordinates + end, // ending direction in local coordinates + turnradius, // turning radius in local coordinates + transform // linear distortion between page and local +) { + var a = setupArc(r0, end, turnradius), + sradius = a.sradius, dc = a.dc, + r1, a1r, a2r, j, r, pts, triples, + splits, splita, absang, relative, points; + // Decompose an arc into equal arcs, all 45 degrees or less. + splits = 1; + splita = a.delta; + absang = Math.abs(a.delta); + if (absang > 45) { + splits = Math.ceil(absang / 45); + splita = a.delta / splits; + } + // Relative traces out the unit-radius arc centered at the origin. + relative = []; + while (--splits >= 0) { + r1 = splits === 0 ? end : r0 + splita; + a1r = convertToRadians(r0 + 180); + a2r = convertToRadians(r1 + 180); + relative.push.apply(relative, approxBezierUnitArc(a1r, a2r)); + r0 = r1; + } + points = []; + for (j = 0; j < relative.length; j++) { + // Multiply each coordinate by radius scale up to the right + // turning radius and add to dc to center the turning radius + // at the right local coordinate position; then apply parent + // distortions to get page-coordinate relative offsets to the + // turtle's original position. + r = matrixVectorProduct(transform, + addVector(scaleVector(relative[j], sradius), dc)); + // Finally add these to the turtle's actual original position + // to get page-coordinate control points for the bezier curves. + // (start is the starting position in absolute coordinates, + // and dc is the local coordinate offset from the starting + // position to the center of the turning radius.) + points.push({ + pageX: r[0] + start.pageX, + pageY: r[1] + start.pageY}); + } + // Divide control points into triples again to form bezier curves. + triples = []; + for (j = 0; j < points.length; j += 3) { + triples.push(points.slice(j, j + 3)); + } + addBezierToPath(path, start, triples); + return a; +} + +// An animation driver for rotation, including the possibility of +// tracing out an arc. Reads an element's turningRadius to see if +// changing ts.rot should also sweep out an arc. If so, calls +// addArcBezierPath to directly add that arc to the drawing path. function maybeArcRotation(end, elem, ts, opt) { end = parseFloat(end); var state = $.data(elem, 'turtleData'), tradius = state ? state.turningRadius : 0; - if (tradius === 0) { + if (tradius === 0 || ts.rot == end) { // Avoid drawing a line if zero turning radius. opt.displace = false; - return normalizeRotation(end); + return tradius === 0 ? normalizeRotation(end) : end; } var tracing = (state && state.style && state.down), - r0 = ts.rot, r1, r1r, a1r, a2r, j, r, pts, triples, - r0r = convertToRadians(ts.rot), - delta = normalizeRotationDelta(end - r0), - radius = (delta > 0 ? tradius : -tradius) * ts.sy, - dc = [Math.cos(r0r) * radius, Math.sin(r0r) * radius], - splits, splita, absang, dx, dy, qpxy, - path, totalParentTransform, start, relative, points; + sy = (state && state.oldscale) ? ts.sy : 1, + turnradius = tradius * sy, a; if (tracing) { - // Decompose an arc into equal arcs, all 45 degrees or less. - splits = 1; - splita = delta; - absang = Math.abs(delta); - if (absang > 45) { - splits = Math.ceil(absang / 45); - splita = delta / splits; - } - path = state.path[0]; - totalParentTransform = totalTransform2x2(elem.parentElement); - // Relative traces out the unit-radius arc centered at the origin. - relative = []; - while (--splits >= 0) { - r1 = splits === 0 ? end : r0 + splita; - a1r = convertToRadians(r0 + 180); - a2r = convertToRadians(r1 + 180); - relative.push.apply(relative, approxBezierUnitArc(a1r, a2r)); - r0 = r1; - } - points = []; - // start is the starting position in absolute coordinates, - // and dc is the local coordinate offset from the starting - // position to the center of the turning radius. - start = getCenterInPageCoordinates(elem); - for (j = 0; j < relative.length; j++) { - // Multiply each coordinate by radius scale up to the right - // turning radius and add to dc to center the turning radius - // at the right local coordinate position; then apply parent - // distortions to get page-coordinate relative offsets to the - // turtle's original position. - r = matrixVectorProduct(totalParentTransform, - addVector(scaleVector(relative[j], radius), dc)); - // Finally add these to the turtle's actual original position - // to get page-coordinate control points for the bezier curves. - points.push({ - pageX: r[0] + start.pageX, - pageY: r[1] + start.pageY}); - } - // Divide control points into triples again to form bezier curves. - triples = []; - for (j = 0; j < points.length; j += 3) { - triples.push(points.slice(j, j + 3)); - } - addBezierToPath(path, start, triples); - } - // Now move turtle to its final position: in local coordinates, - // translate to the turning center plus the vector to the arc end. - r1r = convertToRadians(end); - dx = dc[0] - Math.cos(r1r) * radius; - dy = dc[1] - Math.sin(r1r) * radius; - ts.tx += dx; - ts.ty += dy; + a = addArcBezierPaths( + state.path[0], // path to add to + getCenterInPageCoordinates(elem), // starting location + ts.rot, // starting direction + end, // ending direction + turnradius, // scaled turning radius + totalTransform2x2(elem.parentElement)); // totalParentTransform + } else { + a = setupArc( + ts.rot, // starting direction + end, // degrees change + turnradius); // scaled turning radius + } + ts.tx += a.dx; + ts.ty += a.dy; opt.displace = true; return end; } @@ -2253,8 +2626,8 @@ function makeTurtleXYHook(publicname, propx, propy, displace) { state = $.data(elem, 'turtleData'), otx = ts.tx, oty = ts.ty, qpxy; if (parts.length < 1 || parts.length > 2) { return; } - if (parts.length >= 1) { ts[propx] = parts[0]; } - if (parts.length >= 2) { ts[propy] = parts[1]; } + if (parts.length >= 1) { ts[propx] = parseFloat(parts[0]); } + if (parts.length >= 2) { ts[propy] = parseFloat(parts[1]); } else if (!displace) { ts[propy] = ts[propx]; } else { ts[propy] = 0; } elem.style[transform] = writeTurtleTransform(ts); @@ -2265,12 +2638,73 @@ function makeTurtleXYHook(publicname, propx, propy, displace) { pageY: qpxy.pageY + (ts.ty - oty) }; } - flushPenState(elem); + flushPenState(elem, state); + } else { + clearChildQuickLocations(elem); } } }; } +var absoluteUrlAnchor = document.createElement('a'); +function absoluteUrlObject(url) { + absoluteUrlAnchor.href = url; + return absoluteUrlAnchor; +} +function absoluteUrl(url) { + return absoluteUrlObject(url).href; +} + +// Pencil-code specific function: detects whether a domain appears to +// be a pencilcode site. +function isPencilHost(hostname) { + return /(?:^|\.)pencil(?:code)?\./i.test(hostname); +} +// Returns a pencilcode username from the URL, if any. +function pencilUserFromUrl(url) { + var hostname = absoluteUrlObject(url == null ? '' : url).hostname, + match = /^(\w+)\.pencil(?:code)?\./i.exec(hostname); + if (match) return match[1]; + return null; +} +// Rewrites a url to have the top directory name given. +function apiUrl(url, topdir) { + var link = absoluteUrlObject(url == null ? '' : url), + result = link.href; + if (isPencilHost(link.hostname)) { + if (/^\/(?:edit|home|code|load|save)(?:\/|$)/.test(link.pathname)) { + // Replace a special topdir name. + result = link.protocol + '//' + link.host + '/' + topdir + '/' + + link.pathname.replace(/\/[^\/]*(?:\/|$)/, '') + link.search + link.hash; + } + } else if (isPencilHost(global.location.hostname)) { + // Proxy offdomain requests to avoid CORS issues. + result = '/proxy/' + result; + } + return result; +} +// Creates an image url from a potentially short name. +function imgUrl(url) { + if (/\//.test(url)) { return url; } + url = '/img/' + url; + if (isPencilHost(global.location.hostname)) { return url; } + return '//pencilcode.net' + url; +} +// Retrieves the pencil code login cookie, if there is one. +function loginCookie() { + if (!document.cookie) return null; + var cookies = document.cookie.split(/;\s*/); + for (var j = 0; j < cookies.length; ++j) { + if (/^login=/.test(cookies[j])) { + var val = unescape(cookies[j].substr(6)).split(':'); + if (val && val.length == 2) { + return { user: val[0], key: val[1] }; + } + } + } + return null; +} + // A map of url to {img: Image, queue: [{elem: elem, css: css, cb: cb}]}. var stablyLoadedImages = {}; @@ -2291,18 +2725,21 @@ var stablyLoadedImages = {}; // @param css is a dictionary of css props to set when the image is loaded. // @param cb is an optional callback, called after the loading is done. function setImageWithStableOrigin(elem, url, css, cb) { - var record; + var record, urlobj = absoluteUrlObject(url); + url = urlobj.href; // The data-loading attr will always reflect the last URL requested. elem.setAttribute('data-loading', url); if (url in stablyLoadedImages) { // Already requested this image? record = stablyLoadedImages[url]; - if (record.img.complete) { + if (record.queue === null) { // If already complete, then flip the image right away. finishSet(record.img, elem, css, cb); } else { // If not yet complete, then add the target element to the queue. record.queue.push({elem: elem, css: css, cb: cb}); + // Pop the element to the right dimensions early if possible. + resizeEarlyIfPossible(url, elem, css); } } else { // Set up a new image load. @@ -2310,19 +2747,15 @@ function setImageWithStableOrigin(elem, url, css, cb) { img: new Image(), queue: [{elem: elem, css: css, cb: cb}] }; + if (isPencilHost(urlobj.hostname)) { + // When requesting through pencilcode, always make a + // cross-origin request. + record.img.crossOrigin = 'Anonymous'; + } + // Pop the element to the right dimensions early if possible. + resizeEarlyIfPossible(url, elem, css); // First set up the onload callback, then start loading. - record.img.addEventListener('load', poll); - record.img.addEventListener('error', poll); - record.img.src = url; - function poll() { - if (!record.img.complete) { - // Guard against browsers that may fire onload too early or never. - setTimeout(poll, 100); - return; - } - record.img.removeEventListener('load', poll); - record.img.removeEventListener('error', poll); - // TODO: compute the convex hull of the image. + afterImageLoadOrError(record.img, url, function() { var j, queue = record.queue; record.queue = null; if (queue) { @@ -2331,9 +2764,7 @@ function setImageWithStableOrigin(elem, url, css, cb) { finishSet(record.img, queue[j].elem, queue[j].css, queue[j].cb); } } - } - // Start polling immediatey, because some browser may never fire onload. - poll(); + }); } // This is the second step, done after the async load is complete: // the parameter "loaded" contains the fully loaded Image. @@ -2343,36 +2774,7 @@ function setImageWithStableOrigin(elem, url, css, cb) { // some subsequent load that has now superceded ours. if (elem.getAttribute('data-loading') == loaded.src) { elem.removeAttribute('data-loading'); - // Read the element's origin before setting the image src. - var oldOrigin = readTransformOrigin(elem); - // Set the image to a 1x1 transparent GIF, and clear the transform origin. - // (This "reset" code was original added in an effort to avoid browser - // bugs, but it is not clear if it is still needed.) - elem.src = 'data:image/gif;base64,R0lGODlhAQABAIAAA' + - 'AAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; - var sel = $(elem); - sel.css({ - backgroundImage: 'none', - height: '', - width: '', - transformOrigin: '' - }); - // Now set the source, and then apply any css requested. - sel[0].width = loaded.width; - sel[0].height = loaded.height; - sel[0].src = loaded.src; - if (css) { - sel.css(css); - } - var newOrigin = readTransformOrigin(elem); - // If there was a change, then translate the element to keep the origin - // in the same location on the screen. - if (newOrigin[0] != oldOrigin[0] || newOrigin[1] != oldOrigin[1]) { - var ts = readTurtleTransform(elem, true); - ts.tx += oldOrigin[0] - newOrigin[0]; - ts.ty += oldOrigin[1] - newOrigin[1]; - elem.style[transform] = writeTurtleTransform(ts); - } + applyLoadedImage(loaded, elem, css); } // Call the callback, if any. if (cb) { @@ -2381,6 +2783,135 @@ function setImageWithStableOrigin(elem, url, css, cb) { } } +function afterImageLoadOrError(img, url, fn) { + if (url == null) { url = img.src; } + // If already loaded, then just call fn. + if (url == img.src && (!url || img.complete)) { + fn(); + return; + } + // Otherwise, set up listeners and wait. + var timeout = null; + function poll(e) { + // If we get a load or error event, notice img.complete + // or see that the src was changed, we're done here. + if (e || img.complete || img.src != url) { + img.removeEventListener('load', poll); + img.removeEventListener('error', poll); + clearTimeout(timeout); + fn(); + } else { + // Otherwise, continue waiting and also polling. + timeout = setTimeout(poll, 100); + } + } + img.addEventListener('load', poll); + img.addEventListener('error', poll); + img.src = url; + poll(); +} + +// In the special case of loading a data: URL onto an element +// where we also have an explicit css width and height to apply, +// we go ahead and synchronously apply the CSS properties even if +// the URL isn't yet marked as loaded. This is needed to allow +// "wear pointer" to act synchronously for tests even though +// PhantomJS asynchronously loads data: url images. (Note that +// Chrome syncrhonously loads data: url images, so this is a +// dead code path on Chrome.) +function resizeEarlyIfPossible(url, elem, css) { + if (/^data:/.test(url) && css.width && css.height) { + applyLoadedImage(null, elem, css); + } +} + +function applyLoadedImage(loaded, elem, css) { + // Read the element's origin before setting the image src. + var oldOrigin = readTransformOrigin(elem), + sel = $(elem), + isCanvas = (elem.tagName == 'CANVAS'), + ctx; + if (!isCanvas) { + // Set the image to a 1x1 transparent GIF, and clear the transform origin. + // (This "reset" code was original added in an effort to avoid browser + // bugs, but it is not clear if it is still needed.) + elem.src = 'data:image/gif;base64,R0lGODlhAQABAIAAA' + + 'AAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + } + sel.css({ + backgroundImage: 'none', + height: '', + width: '', + turtleHull: '', + transformOrigin: '' + }); + if (loaded) { + // Now set the source, and then apply any css requested. + if (loaded.tagName == 'VIDEO') { + elem.width = $(loaded).width(); + elem.height = $(loaded).height(); + if (isCanvas) { + ctx = elem.getContext('2d'); + ctx.clearRect(0, 0, elem.width, elem.height); + ctx.drawImage(loaded, 0, 0, loaded.videoWidth, loaded.videoHeight, + 0, 0, elem.width, elem.height); + } + } else { + elem.width = loaded.width; + elem.height = loaded.height; + if (!isCanvas) { + elem.src = loaded.src; + } else if (loaded.width > 0 && loaded.height > 0) { + try { + ctx = elem.getContext('2d'); + ctx.clearRect(0, 0, loaded.width, loaded.height); + ctx.drawImage(loaded, 0, 0); + } catch (e) { } + } + } + } + if (css) { + sel.css(css); + } + var newOrigin = readTransformOrigin(elem); + if (loaded && !css.turtleHull) { + try { + var hull = transparentHull(loaded); + scalePolygon(hull, + parseFloat(sel.css('width')) / loaded.width, + parseFloat(sel.css('height')) / loaded.height, + -newOrigin[0], -newOrigin[1]); + sel.css('turtleHull', hull); + } catch (e) { + // Do not do this if the image can't be loaded. + } + } + moveToPreserveOrigin(elem, oldOrigin, newOrigin); +} + +function moveToPreserveOrigin(elem, oldOrigin, newOrigin) { + var sel = $(elem); + if (!sel.hasClass('turtle')) return; + // If there was a change, then translate the element to keep the origin + // in the same location on the screen. + if (newOrigin[0] != oldOrigin[0] || newOrigin[1] != oldOrigin[1]) { + if (sel.css('position') == 'absolute' && + /px$/.test(sel.css('left')) && /px$/.test(sel.css('top'))) { + // Do the translation using absolute positioning, if absolute. + sel.css('left', + parseFloat(sel.css('left')) + oldOrigin[0] - newOrigin[0]); + sel.css('top', + parseFloat(sel.css('top')) + oldOrigin[1] - newOrigin[1]); + } else { + // Do the translation using CSS transforms otherwise. + var ts = readTurtleTransform(elem, true); + ts.tx += oldOrigin[0] - newOrigin[0]; + ts.ty += oldOrigin[1] - newOrigin[1]; + elem.style[transform] = writeTurtleTransform(ts); + } + } +} + function withinOrNot(obj, within, distance, x, y) { var sel, elem, gbcr, pos, d2; if (x === undefined && y === undefined) { @@ -2448,19 +2979,22 @@ function withinOrNot(obj, within, distance, x, y) { // Classes to allow jQuery to be subclassed. ////////////////////////////////////////////////////////////////////////// -// A class to wrap jQuery -Pencil = (function(_super) { - __extends(Pencil, _super); +// Sprite extends the jQuery object prototype. +var Sprite = (function(_super) { + __extends(Sprite, _super); - function Pencil(selector, context) { + function Sprite(selector, context) { this.constructor = jQuery; this.constructor.prototype = Object.getPrototypeOf(this); - if ('function' !== typeof selector) { - jQuery.fn.init.call(this, selector, context, rootjQuery); + if (!selector || typeof(selector) == 'string' || + $.isPlainObject(selector) || typeof(selector) == 'number') { + // Use hatchone to create an element. + selector = hatchone(selector, context, '256x256'); } + jQuery.fn.init.call(this, selector, context, rootjQuery); } - Pencil.prototype.pushStack = function() { + Sprite.prototype.pushStack = function() { var count, ret, same; ret = jQuery.fn.pushStack.apply(this, arguments); count = ret.length; @@ -2475,47 +3009,2643 @@ Pencil = (function(_super) { } }; - return Pencil; + return Sprite; })(jQuery.fn.init); -Turtle = (function(_super) { +// Pencil extends Sprite, and is invisible and fast by default. +var Pencil = (function(_super) { + __extends(Pencil, _super); + + function Pencil(canvas) { + // A pencil draws on a canvas. Allow a selector or element. + if (canvas && canvas.jquery && $.isFunction(canvas.canvas)) { + canvas = canvas.canvas(); + } + if (canvas && (canvas.tagName != 'CANVAS' || + typeof canvas.getContext != 'function')) { + canvas = $(canvas).filter('canvas').get(0); + } + if (!canvas || canvas.tagName != 'CANVAS' || + typeof canvas.getContext != 'function') { + canvas = null; + } + // The pencil is a sprite that just defaults to zero size. + var context = canvas ? canvas.parentElement : null; + var settings = { width: 0, height: 0, color: 'transparent' }; + Pencil.__super__.constructor.call(this, settings, context); + // Set the pencil to hidden, infinite speed, + // and drawing on the specifed canvas. + this.each(function() { + var state = getTurtleData(this); + state.speed = Infinity; + state.drawOnCanvas = canvas; + this.style.display = 'none'; + if (canvas) { + this.style[transform] = writeTurtleTransform( + readTurtleTransform(canvas, true)); + } + }); + } + + return Pencil; + +})(Sprite); + + +// Turtle extends Sprite, and draws a turtle by default. +var Turtle = (function(_super) { __extends(Turtle, _super); function Turtle(arg, context) { - Turtle.__super__.constructor.call(this, hatchone(arg, context)); + // The turtle is a sprite that just defaults to the turtle shape. + Turtle.__super__.constructor.call(this, hatchone(arg, context, 'turtle')); } return Turtle; -})(Pencil); - -////////////////////////////////////////////////////////////////////////// -// JQUERY REGISTRATION -// Register all our hooks. -////////////////////////////////////////////////////////////////////////// +})(Sprite); -$.extend(true, $, { - cssHooks: { - turtlePenStyle: makePenStyleHook(), - turtlePenDown: makePenDownHook(), - turtleSpeed: makeTurtleSpeedHook(), - turtleEasing: makeTurtleEasingHook(), - turtleForward: makeTurtleForwardHook(), - turtleTurningRadius: makeTurningRadiusHook(), - turtlePosition: makeTurtleXYHook('turtlePosition', 'tx', 'ty', true), - turtlePositionX: makeTurtleHook('tx', identity, 'px', true), - turtlePositionY: makeTurtleHook('ty', identity, 'px', true), - turtleRotation: makeTurtleHook('rot', maybeArcRotation, 'deg', true), - turtleScale: makeTurtleXYHook('turtleScale', 'sx', 'sy', false), - turtleScaleX: makeTurtleHook('sx', identity, '', false), - turtleScaleY: makeTurtleHook('sy', identity, '', false), - turtleTwist: makeTurtleHook('twi', normalizeRotation, 'deg', false), - turtleHull: makeHullHook() - }, - cssNumber: { - turtleRotation: true, - turtleSpeed: true, +// Webcam extends Sprite, and draws a live video camera by default. +var Webcam = (function(_super) { + __extends(Webcam, _super); + function Webcam(opts, context) { + var attrs = "", hassrc = false, hasautoplay = false, hasdims = false; + if ($.isPlainObject(opts)) { + for (var key in opts) { + attrs += ' ' + key + '="' + escapeHtml(opts[key]) + '"'; + } + hassrc = ('src' in opts); + hasautoplay = ('autoplay' in opts); + hasdims = ('width' in opts || 'height' in opts); + if (hasdims && !('height' in opts)) { + attrs += ' height=' + Math.round(opts.width * 3/4); + } + if (hasdims && !('width' in opts)) { + attrs += ' width=' + Math.round(opts.height * 4/3); + } + } + if (!hasautoplay) { + attrs += ' autoplay'; + } + if (!hasdims) { + attrs += ' width=320 height=240'; + } + Webcam.__super__.constructor.call(this, ''); + if (!hassrc) { + this.capture(); + } + } + Webcam.prototype.capture = function() { + return this.queue(function(next) { + var v = this, + getUserMedia = navigator.getUserMedia || + navigator.webkitGetUserMedia || + navigator.mozGetUserMedia || + navigator.msGetUserMedia; + if (!getUserMedia) { next(); return; } + getUserMedia.call(navigator, {video: true}, function(stream) { + if (stream) { + var state = getTurtleData(v), k = ('' + Math.random()).substr(2); + if (state.stream) { + state.stream.stop(); + } + state.stream = stream; + $(v).on('play.capture' + k, function() { + $(v).off('play.capture' + k); + next(); + }); + v.src = global.URL.createObjectURL(stream); + } + }, function() { + next(); + }); + }); + }; + // Disconnects the media stream. + Webcam.prototype.cut = function() { + return this.plan(function() { + var state = this.data('turtleData'); + if (state.stream) { + state.stream.stop(); + state.stream = null; + } + this.attr('src', ''); + }); + }; + return Webcam; +})(Sprite); + +// Piano extends Sprite, and draws a piano keyboard by default. +var Piano = (function(_super) { + __extends(Piano, _super); + // The piano constructor accepts an options object that can have: + // keys: the number of keys. (this is the default property) + // color: the color of the white keys. + // blackColor: the color of the black keys. + // lineColor: the color of the key outlines. + // width: the overall keyboard pixel width. + // height: the overall keyboard pixel height. + // lineWidth: the outline line width. + // lowest: the lowest key number (as a midi number). + // highest: the highest key number (as a midi number). + // timbre: an Instrument timbre object or string. + // Any subset of these properties may be supplied, and reasonable + // defaults are chosen for everything else. For example, + // new Piano(88) will create a standard 88-key Piano keyboard. + function Piano(options) { + var aspect, defwidth, extra, firstwhite, height, width, lastwhite, + numwhite = null, self = this, key, timbre, lowest, highest; + options = parseOptionString(options, 'keys'); + // Convert options lowest and highest to midi numbers, if given. + lowest = Instrument.pitchToMidi(options.lowest); + highest = Instrument.pitchToMidi(options.highest); + // The purpose of the logic in the constructor below is to calculate + // reasonable defaults for the geometry of the keyboard given any + // subset of options. The geometric measurments go into _geom. + var geom = this._geom = {} + geom.lineWidth = ('lineWidth' in options) ? options.lineWidth : 1.5; + geom.color = ('color' in options) ? options.color : 'white'; + geom.blackColor = ('blackColor' in options) ? options.blackColor : 'black'; + geom.lineColor = ('lineColor' in options) ? options.lineColor : 'black'; + // The extra pixel amount added to the bottom and right to take into + // account the line width. + extra = Math.ceil(geom.lineWidth); + // Compute dimensions: first, default to 422 pixels wide and 100 tall. + defwidth = 422; + aspect = 4.2; + // But if a key count is specified, default width to keep each white + // key (i.e., 7/12ths of the keys) about 5:1 tall and the total + // keyboard area about 42000 pixels. + if (lowest != null && highest != null) { + numwhite = wcp(highest) - wcp(lowest) + 1; + } else if ('keys' in options) { + numwhite = Math.ceil(options.keys / 12 * 7); + } + if (numwhite) { + aspect = numwhite / 5; + defwidth = Math.sqrt(42000 * aspect) + extra; + } + // If not specified explicitly, compute width from height, or if that + // was not specified either, use the default width. + width = ('width' in options) ? options.width : ('height' in options) ? + Math.round((options.height - extra) * aspect + extra): defwidth; + // Compute the height from width if not specified. + height = ('height' in options) ? options.height : + Math.round((width - extra) / aspect + extra); + // If no key count, then come up with one based on geometry. + if (!numwhite) { + numwhite = + Math.max(1, Math.round((width - extra) / (height - extra) * 5)); + } + // Default rightmost white key by centering at F above middle C, up to C8. + // For example, for 36 keys, there are 21 white keys, and the last + // white key is 42 + (21 - 1) / 2 = 52, the B ten white keys above the F. + lastwhite = Math.min(wcp(108), Math.ceil(42 + (numwhite - 1) / 2)); + // If the highest midi key is not specified, then use the default. + geom.highest = + (highest != null) ? highest : + (lowest != null && 'keys' in options) ? lowest + options.keys - 1 : + (lowest != null) ? mcp(wcp(lowest) + numwhite - 1) : + mcp(lastwhite); + // If the lowest midi key is not specified, then pick one. + geom.lowest = + (lowest != null) ? lowest : + ('keys' in options) ? geom.highest - options.keys + 1 : + Math.min(geom.highest, mcp(wcp(geom.highest) - numwhite + 1)); + // Final geometry computation. + firstwhite = wcp(geom.lowest); + lastwhite = wcp(geom.highest); + // If highest is a black key, add the space of an extra white key. + if (isblackkey(geom.highest)) { lastwhite += 1; } + numwhite = lastwhite - firstwhite + 1; + // Width and height of a single white key. + geom.kw = (width - extra) / numwhite; + geom.kh = (('height' in options) ? options.height - extra : geom.kw * 5) + + (extra - geom.lineWidth); // Add roundoff to align with sprite bottom. + // Width and height of a single black key. + geom.bkw = geom.kw * 4 / 7; + geom.bkh = geom.kh * 3 / 5; + // Pixel offsets for centering the keyboard. + geom.halfex = extra / 2; + geom.leftpx = firstwhite * geom.kw; + geom.rightpx = (lastwhite + 1) * geom.kw; + // The top width of a C key and an F key (making space for black keys). + geom.ckw = (3 * geom.kw - 2 * geom.bkw) / 3; + geom.fkw = (4 * geom.kw - 3 * geom.bkw) / 4; + Piano.__super__.constructor.call(this, { + width: Math.ceil(geom.rightpx - geom.leftpx + extra), + height: Math.ceil(geom.kh + extra) + }); + // The following is a simplistic wavetable simulation of a Piano sound. + if ('timbre' in options) { + timbre = options.timbre; + } else { + // Allow timbre to be passed directly as options params. + for (key in Instrument.defaultTimbre) { + if (key in options) { + if (!timbre) { timbre = {}; } + timbre[key] = options[key]; + } + } + } + if (!timbre) { timbre = 'piano'; } + this.css({ turtleTimbre: timbre }); + // Hook up events. + this.on('noteon', function(e) { + self.drawkey(e.midi, keycolor(e.midi)); + }); + this.on('noteoff', function(e) { + self.drawkey(e.midi); + }); + this.draw(); + return this; + } + + // Draws the key a midi number n, using the provided fill color + // (defaults to white or black as appropriate). + Piano.prototype.drawkey = function(n, fillcolor) { + var ctx, geom = this._geom; + if (!((geom.lowest <= n && n <= geom.highest))) { + return; + } + if (fillcolor == null) { + if (isblackkey(n)) { + fillcolor = geom.blackColor; + } else { + fillcolor = geom.color; + } + } + ctx = this.canvas().getContext('2d'); + ctx.save(); + ctx.beginPath(); + keyoutline(ctx, geom, n); + ctx.fillStyle = fillcolor; + ctx.strokeStyle = geom.lineColor; + ctx.lineWidth = geom.lineWidth; + ctx.fill(); + ctx.stroke(); + return ctx.restore(); + }; + + // Draws every key on the keyboard. + Piano.prototype.draw = function() { + for (var n = this._geom.lowest; n <= this._geom.highest; ++n) { + this.drawkey(n); + } + }; + + var colors12 = [ + '#db4437', // C red + '#ff5722', // C# orange + '#f4b400', // D orange yellow + '#ffeb3b', // D# yellow + '#cddc39', // E lime + '#0f9d58', // F green + '#00bcd4', // F# teal + '#03a9f4', // G light blue + '#4285f4', // G# blue + '#673ab7', // A deep purple + '#9c27b0', // A# purple + '#e91e63' // B pink + ]; + + // Picks a "noteon" color for a midi key number. + function keycolor(n) { + return colors12[(n % 12 + 12) % 12]; + }; + + // Converts a midi number to a white key position (black keys round left). + function wcp(n) { + return Math.floor((n + 7) / 12 * 7); + }; + + // Converts from a white key position to a midi number. + function mcp(n) { + return Math.ceil(n / 7 * 12) - 7; + }; + + // True if midi #n is a black key. + function isblackkey(n) { + return keyshape(n) >= 8; + } + + // Returns 1-8 for white keys CDEFGAB, and 9-12 for black keys C#D#F#G#A#. + function keyshape(n) { + return [1, 8, 2, 9, 3, 4, 10, 5, 11, 6, 12, 7][((n % 12) + 12) % 12]; + }; + + // Given a 2d drawing context and geometry params, outlines midi key #n. + function keyoutline(ctx, geom, n) { + var ks, lcx, leftmost, rcx, rightmost, startx, starty; + // The lower-left corner of the nearest (rounding left) white key. + startx = geom.halfex + geom.kw * wcp(n) - geom.leftpx; + starty = geom.halfex; + // Compute the 12 cases of key shapes, plus special cases for the ends. + ks = keyshape(n); + leftmost = n === geom.lowest; + rightmost = n === geom.highest; + // White keys can have two cutouts: lcx is the x measurement of the + // left cutout and rcx is the x measurement of the right cutout. + lcx = 0; + rcx = 0; + switch (ks) { + case 1: // C + rcx = geom.kw - geom.ckw; + break; + case 2: // D + rcx = lcx = (geom.kw - geom.ckw) / 2; + break; + case 3: // E + lcx = geom.kw - geom.ckw; + break; + case 4: // F + rcx = geom.kw - geom.fkw; + break; + case 5: // G + lcx = geom.fkw + geom.bkw - geom.kw; + rcx = 2 * geom.kw - 2 * geom.fkw - geom.bkw; + break; + case 6: // A + lcx = 2 * geom.kw - 2 * geom.fkw - geom.bkw; + rcx = geom.fkw + geom.bkw - geom.kw; + break; + case 7: // B + lcx = geom.kw - geom.fkw; + break; + case 8: // C# + startx += geom.ckw; + break; + case 9: // D# + startx += 2 * geom.ckw + geom.bkw - geom.kw; + break; + case 10: // F# + startx += geom.fkw; + break; + case 11: // G# + startx += 2 * geom.fkw + geom.bkw - geom.kw; + break; + case 12: // A# + startx += 3 * geom.fkw + 2 * geom.bkw - 2 * geom.kw; + } + if (leftmost) { + lcx = 0; + } + if (rightmost) { + rcx = 0; + } + if (isblackkey(n)) { + // A black key is always a rectangle. Startx is computed above. + ctx.moveTo(startx, starty + geom.bkh); + ctx.lineTo(startx + geom.bkw, starty + geom.bkh); + ctx.lineTo(startx + geom.bkw, starty); + ctx.lineTo(startx, starty); + return ctx.closePath(); + } else { + // A white keys is a rectangle with two cutouts. + ctx.moveTo(startx, starty + geom.kh); + ctx.lineTo(startx + geom.kw, starty + geom.kh); + ctx.lineTo(startx + geom.kw, starty + geom.bkh); + ctx.lineTo(startx + geom.kw - rcx, starty + geom.bkh); + ctx.lineTo(startx + geom.kw - rcx, starty); + ctx.lineTo(startx + lcx, starty); + ctx.lineTo(startx + lcx, starty + geom.bkh); + ctx.lineTo(startx, starty + geom.bkh); + return ctx.closePath(); + } + }; + + return Piano; + +})(Sprite); + +////////////////////////////////////////////////////////////////////////// +// KEYBOARD HANDLING +// Implementation of the "pressed" function +////////////////////////////////////////////////////////////////////////// + +var focusTakenOnce = false; +function focusWindowIfFirst() { + if (focusTakenOnce) return; + focusTakenOnce = true; + try { + // If we are in a frame with access to a parent with an activeElement, + // then try to blur it (as is common inside the pencilcode IDE). + global.parent.document.activeElement.blur(); + } catch (e) {} + global.focus(); +} + +// Construction of keyCode names. +var keyCodeName = (function() { + var ua = typeof global !== 'undefined' ? global.navigator.userAgent : '', + isOSX = /OS X/.test(ua), + isOpera = /Opera/.test(ua), + maybeFirefox = !/like Gecko/.test(ua) && !isOpera, + pressedState = {}, + preventable = 'contextmenu', + events = 'mousedown mouseup keydown keyup blur ' + preventable, + keyCodeName = { + 0: 'null', + 1: 'mouse1', + 2: 'mouse2', + 3: 'break', + 4: 'mouse3', + 5: 'mouse4', + 6: 'mouse5', + 8: 'backspace', + 9: 'tab', + 12: 'clear', + 13: 'enter', + 16: 'shift', + 17: 'control', + 18: 'alt', + 19: 'pause', + 20: 'capslock', + 21: 'hangulmode', + 23: 'junjamode', + 24: 'finalmode', + 25: 'kanjimode', + 27: 'escape', + 28: 'convert', + 29: 'nonconvert', + 30: 'accept', + 31: 'modechange', + 27: 'escape', + 32: 'space', + 33: 'pageup', + 34: 'pagedown', + 35: 'end', + 36: 'home', + 37: 'left', + 38: 'up', + 39: 'right', + 40: 'down', + 41: 'select', + 42: 'print', + 43: 'execute', + 44: 'snapshot', + 45: 'insert', + 46: 'delete', + 47: 'help', + // no one handles meta-left and right properly, so we coerce into one. + 91: 'meta', // meta-left + 92: 'meta', // meta-right + // chrome,opera,safari all report this for meta-right (osx mbp). + 93: isOSX ? 'meta' : 'menu', + 95: 'sleep', + 106: 'numpad*', + 107: 'numpad+', + 108: 'numpadenter', + 109: 'numpad-', + 110: 'numpad.', + 111: 'numpad/', + 144: 'numlock', + 145: 'scrolllock', + 160: 'shiftleft', + 161: 'shiftright', + 162: 'controlleft', + 163: 'controlright', + 164: 'altleft', + 165: 'altright', + 166: 'browserback', + 167: 'browserforward', + 168: 'browserrefresh', + 169: 'browserstop', + 170: 'browsersearch', + 171: 'browserfavorites', + 172: 'browserhome', + // ff/osx reports 'volume-mute' for '-' + 173: isOSX && maybeFirefox ? '-' : 'volumemute', + 174: 'volumedown', + 175: 'volumeup', + 176: 'mediatracknext', + 177: 'mediatrackprev', + 178: 'mediastop', + 179: 'mediaplaypause', + 180: 'launchmail', + 181: 'launchmediaplayer', + 182: 'launchapp1', + 183: 'launchapp2', + 186: ';', + 187: '=', + 188: ',', + 189: '-', + 190: '.', + 191: '/', + 192: '`', + 219: '[', + 220: '\\', + 221: ']', + 222: "'", + 223: 'meta', + 224: 'meta', // firefox reports meta here. + 226: 'altgraph', + 229: 'process', + 231: isOpera ? '`' : 'unicode', + 246: 'attention', + 247: 'crsel', + 248: 'exsel', + 249: 'eraseeof', + 250: 'play', + 251: 'zoom', + 252: 'noname', + 253: 'pa1', + 254: 'clear' + }; + // :-@, 0-9, a-z(lowercased) + for (var i = 48; i < 91; ++i) { + keyCodeName[i] = String.fromCharCode(i).toLowerCase(); + } + // num-0-9 numeric keypad + for (i = 96; i < 106; ++i) { + keyCodeName[i] = 'numpad' + (i - 96); + } + // f1-f24 + for (i = 112; i < 136; ++i) { + keyCodeName[i] = 'f' + (i-111); + } + return keyCodeName; +})(); + +var pressedKey = (function() { + // Listener for keyboard, mouse, and focus events that updates pressedState. + function makeEventListener(mouse, down) { + return (function(event) { + var name, simplified, which = event.which; + if (mouse) { + name = 'mouse' + which; + } else { + // For testability, support whichSynth when which is zero, because + // it is impossible to simulate .which on phantom. + if (!which && event.whichSynth) { which = event.whichSynth; } + name = keyCodeName[which]; + if (which >= 160 && which <= 165) { + // For "shift left", also trigger "shift"; same for control and alt. + updatePressedState(name.replace(/(?:left|right)$/, ''), down); + } + } + updatePressedState(name, down); + }); + }; + var eventMap = { + 'mousedown': makeEventListener(1, 1), + 'mouseup': makeEventListener(1, 0), + 'keydown': makeEventListener(0, 1), + 'keyup': makeEventListener(0, 0), + 'blur': resetPressedState + }; + // The pressedState map just has an entry for each pressed key. + // Unpressing a key will delete the actual key from the map. + var pressedState = {}; + function updatePressedState(name, down) { + if (name != null) { + if (!down) { + delete pressedState[name]; + } else { + pressedState[name] = true; + } + } + } + // The state map is reset by clearing every member. + function resetPressedState() { + for (var key in pressedState) { + delete pressedState[key]; + } + } + // The pressed listener can be turned on and off using pressed.enable(flag). + function enablePressListener(turnon) { + resetPressedState(); + for (var name in eventMap) { + if (turnon) { + global.addEventListener(name, eventMap[name], true); + } else { + global.removeEventListener(name, eventMap[name]); + } + } + } + // All pressed keys known can be listed using pressed.list(). + function listPressedKeys() { + var result = [], key; + for (key in pressedState) { + if (pressedState[key]) { result.push(key); } + } + return result; + } + // The pressed function just polls the given keyname. + function pressed(keyname) { + focusWindowIfFirst(); + if (keyname) { + // Canonical names are lowercase and have no spaces. + keyname = keyname.replace(/\s/g, '').toLowerCase(); + if (pressedState[keyname]) return true; + return false; + } else { + return listPressedKeys(); + } + } + pressed.enable = enablePressListener; + pressed.list = listPressedKeys; + return pressed; +})(); + + +////////////////////////////////////////////////////////////////////////// +// JQUERY EVENT ENHANCEMENT +// - Keyboard events get the .key property. +// - Keyboard event listening with a string first (data) arg +// automatically filter out events that don't match the keyname. +// - Mouse events get .x and .y (center-up) if there is a turtle field. +// - If a turtle in the field is listening to mouse events, unhandled +// body mouse events are manually forwarded to turtles. +////////////////////////////////////////////////////////////////////////// + +function addEventHook(hookobj, field, defobj, name, fn) { + var names = name.split(/\s+/); + for (var j = 0; j < names.length; ++j) { + name = names[j]; + var hooks = hookobj[name]; + if (!hooks) { + hooks = hookobj[name] = $.extend({}, defobj); + } + if (typeof hooks[field] != 'function') { + hooks[field] = fn; + } else if (hooks[field] != fn) { + // Multiple event hooks just listed in an array. + if (hooks[field].hooklist) { + if (hooks[field].hooklist.indexOf(fn) < 0) { + hooks[field].hooklist.push(fn); + } + } else { + (function() { + var hooklist = [hooks[field], fn]; + (hooks[field] = function(event, original) { + var current = event; + for (var j = 0; j < hooklist.length; ++j) { + current = hooklist[j](current, original) || current; + } + return current; + }).hooklist = hooklist; + })(); + } + } + } +} + +function mouseFilterHook(event, original) { + if (globalDrawing.field && 'pageX' in event && 'pageY' in event) { + var origin = $(globalDrawing.field).offset(); + if (origin) { + event.x = event.pageX - origin.left; + event.y = origin.top - event.pageY; + } + } + return event; +} + +function mouseSetupHook(data, ns, fn) { + if (globalDrawing.field && !globalDrawing.fieldMouse && + this.parentElement === globalDrawing.field || + /(?:^|\s)turtle(?:\s|$)/.test(this.class)) { + globalDrawing.fieldMouse = true; + forwardBodyMouseEventsIfNeeded(); + } + return false; +} + +function forwardBodyMouseEventsIfNeeded() { + if (globalDrawing.fieldHook) return; + if (globalDrawing.surface && globalDrawing.fieldMouse) { + globalDrawing.fieldHook = true; + setTimeout(function() { + // TODO: check both globalDrawing.surface and + // globalDrawing.turtleMouseListener + $('body').on('click.turtle dblclick.turtle ' + + 'mouseup.turtle mousedown.turtle mousemove.turtle', function(e) { + if (e.target === this && !e.isTrigger) { + // Only forward events directly on the body that (geometrically) + // touch a turtle directly within the turtlefield. + var warn = $.turtle.nowarn; + $.turtle.nowarn = true; + var sel = $(globalDrawing.surface) + .find('.turtle,.turtlelabel').within('touch', e).eq(0); + $.turtle.nowarn = warn; + if (sel.length === 1) { + // Erase portions of the event that are wrong for the turtle. + e.target = null; + e.relatedTarget = null; + e.fromElement = null; + e.toElement = null; + sel.trigger(e); + return false; + } + } + }); + }, 0); + } +} + +function addMouseEventHooks() { + var hookedEvents = 'mousedown mouseup mousemove click dblclick'; + addEventHook($.event.fixHooks, 'filter', $.event.mouseHooks, + hookedEvents, mouseFilterHook); + addEventHook($.event.special, 'setup', {}, hookedEvents, mouseSetupHook); +} + +function keyFilterHook(event, original) { + var which = event.which; + if (!which) { + which = (original || event.originalEvent).whichSynth; + } + var name = keyCodeName[which]; + if (!name && which) { + name = String.fromCharCode(which); + } + event.key = name; + return event; +} + +// Add .key to each keyboard event. +function keypressFilterHook(event, original) { + if (event.charCode != null) { + event.key = String.fromCharCode(event.charCode); + } +} + +// Intercept on('keydown/keyup/keypress') +function keyAddHook(handleObj) { + if (typeof(handleObj.data) != 'string') return; + var choices = handleObj.data.replace(/\s/g, '').toLowerCase().split(','); + var original = handleObj.handler; + var wrapped = function(event) { + if (choices.indexOf(event.key) < 0) return; + return original.apply(this, arguments); + } + if (original.guid) { wrapped.guid = original.guid; } + handleObj.handler = wrapped; +} + +function addKeyEventHooks() { + // Add the "key" field to keydown and keyup events - this uses + // the lowercase key names listed in the pressedKey utility. + addEventHook($.event.fixHooks, 'filter', $.event.keyHooks, + 'keydown keyup', keyFilterHook); + // Add "key" to keypress also. This is just the unicode character + // corresponding to event.charCode. + addEventHook($.event.fixHooks, 'filter', $.event.keyHooks, + 'keypress', keypressFilterHook); + // Finally, add special forms for the keyup/keydown/keypress events + // where the first argument can be the comma-separated name of keys + // to target (instead of just data) + addEventHook($.event.special, 'add', {}, + 'keydown keyup keypress', keyAddHook); +} + +////////////////////////////////////////////////////////////////////////// +// WEB AUDIO SUPPORT +// Definition of play("ABC") - uses ABC music note syntax. +////////////////////////////////////////////////////////////////////////// + + +// jQuery CSS hook for turtleTimbre property. +function makeTimbreHook() { + return { + get: function(elem, computed, extra) { + return printOptionAsString(getTurtleInstrument(elem).getTimbre()); + }, + set: function(elem, value) { + getTurtleInstrument(elem).setTimbre(parseOptionString(value, 'wave')); + } + }; +} + +// jQuery CSS hook for turtleVolume property. +function makeVolumeHook() { + return { + get: function(elem, computed, extra) { + return getTurtleInstrument(elem).getVolume(); + }, + set: function(elem, value) { + getTurtleInstrument(elem).setVolume(parseFloat(value)); + } + }; +} + +// Every HTML element gets an instrument. This creates and returns it. +function getTurtleInstrument(elem) { + var state = getTurtleData(elem); + if (state.instrument) { + return state.instrument; + } + state.instrument = new Instrument("piano"); + // Hook up noteon and noteoff events. + var selector = $(elem); + state.instrument.on('noteon', function(r) { + var event = $.Event('noteon'); + event.midi = r.midi; + selector.trigger(event); + }); + state.instrument.on('noteoff', function(r) { + var event = $.Event('noteoff'); + event.midi = r.midi; + selector.trigger(event); + }); + return state.instrument; +} + +// In addition, threre is a global instrument. This funcion returns it. +var global_instrument = null; +function getGlobalInstrument() { + if (!global_instrument) { + global_instrument = new Instrument(); + } + return global_instrument; +} + +// Beginning of musical.js copy + +// Tests for the presence of HTML5 Web Audio (or webkit's version). +function isAudioPresent() { + return !!(global.AudioContext || global.webkitAudioContext); +} + +// All our audio funnels through the same AudioContext with a +// DynamicsCompressorNode used as the main output, to compress the +// dynamic range of all audio. getAudioTop sets this up. +function getAudioTop() { + if (getAudioTop.audioTop) { return getAudioTop.audioTop; } + if (!isAudioPresent()) { + return null; + } + var ac = new (global.AudioContext || global.webkitAudioContext); + getAudioTop.audioTop = { + ac: ac, + wavetable: makeWavetable(ac), + out: null, + currentStart: null + }; + resetAudio(); + return getAudioTop.audioTop; +} + +// When audio needs to be interrupted globally (e.g., when you press the +// stop button in the IDE), resetAudio does the job. +function resetAudio() { + if (getAudioTop.audioTop) { + var atop = getAudioTop.audioTop; + // Disconnect the top-level node and make a new one. + if (atop.out) { + atop.out.disconnect(); + atop.out = null; + atop.currentStart = null; + } + // If resetting due to interrupt after AudioContext closed, this can fail. + try { + var dcn = atop.ac.createDynamicsCompressor(); + dcn.ratio = 16; + dcn.attack = 0.0005; + dcn.connect(atop.ac.destination); + atop.out = dcn; + } catch (e) { + getAudioTop.audioTop = null; + } + } +} + +// For precise scheduling of future notes, the AudioContext currentTime is +// cached and is held constant until the script releases to the event loop. +function audioCurrentStartTime() { + var atop = getAudioTop(); + if (atop.currentStart != null) { + return atop.currentStart; + } + // A delay could be added below to introduce a universal delay in + // all beginning sounds (without skewing durations for scheduled + // sequences). + atop.currentStart = Math.max(0.25, atop.ac.currentTime /* + 0.0 delay */); + setTimeout(function() { atop.currentStart = null; }, 0); + return atop.currentStart; +} + +// Converts a midi note number to a frequency in Hz. +function midiToFrequency(midi) { + return 440 * Math.pow(2, (midi - 69) / 12); +} +// Some constants. +var noteNum = + {C:0,D:2,E:4,F:5,G:7,A:9,B:11,c:12,d:14,e:16,f:17,g:19,a:21,b:23}; +var accSym = + { '^':1, '': 0, '=':0, '_':-1 }; +var noteName = + ['C', '^C', 'D', '_E', 'E', 'F', '^F', 'G', '_A', 'A', '_B', 'B', + 'c', '^c', 'd', '_e', 'e', 'f', '^f', 'g', '_a', 'a', '_b', 'b']; +// Converts a frequency in Hz to the closest midi number. +function frequencyToMidi(freq) { + return Math.round(69 + Math.log(freq / 440) * 12 / Math.LN2); +} +// Converts an ABC pitch (such as "^G,,") to a midi note number. +function pitchToMidi(pitch) { + var m = /^(\^+|_+|=|)([A-Ga-g])([,']*)$/.exec(pitch); + if (!m) { return null; } + var octave = m[3].replace(/,/g, '').length - m[3].replace(/'/g, '').length; + var semitone = + noteNum[m[2]] + accSym[m[1].charAt(0)] * m[1].length + 12 * octave; + return semitone + 60; // 60 = midi code middle "C". +} +// Converts a midi number to an ABC notation pitch. +function midiToPitch(midi) { + var index = ((midi - 72) % 12); + if (midi > 60 || index != 0) { index += 12; } + var octaves = Math.round((midi - index - 60) / 12), + result = noteName[index]; + while (octaves != 0) { + result += octaves > 0 ? "'" : ","; + octaves += octaves > 0 ? -1 : 1; + } + return result; +} +// Converts an ABC pitch to a frequency in Hz. +function pitchToFrequency(pitch) { + return midiToFrequency(pitchToMidi(pitch)); +} + +// All further details of audio handling are encapsulated in the Instrument +// class, which knows how to synthesize a basic timbre; how to play and +// schedule a tone; and how to parse and sequence a song written in ABC +// notation. +var Instrument = (function() { + // The constructor accepts a timbre string or object, specifying + // its default sound. The main mechanisms in Instrument are for handling + // sequencing of a (potentially large) set of notes over a (potentially + // long) period of time. The overall strategy: + // + // Events: 'noteon' 'noteoff' + // | | + // tone()-(quick tones)->| _startSet -->| _finishSet -->| _cleanupSet -->| + // \ | / | Playing tones | Done tones | + // \---- _queue ------|-/ | + // of future tones |3 secs ahead sent to WebAudio, removed when done + // + // The reason for this queuing is to reduce the complexity of the + // node graph sent to WebAudio: at any time, WebAudio is only + // responsible for about 2 seconds of music. If a graph with too + // too many nodes is sent to WebAudio at once, output distorts badly. + function Instrument(options) { + this._atop = getAudioTop(); // Audio context. + this._timbre = makeTimbre(options, this._atop); // The instrument's timbre. + this._queue = []; // A queue of future tones to play. + this._minQueueTime = Infinity; // The earliest time in _queue. + this._maxScheduledTime = 0; // The latest time in _queue. + this._unsortedQueue = false; // True if _queue is unsorted. + this._startSet = []; // Unstarted tones already sent to WebAudio. + this._finishSet = {}; // Started tones playing in WebAudio. + this._cleanupSet = []; // Tones waiting for cleanup. + this._callbackSet = []; // A set of scheduled callbacks. + this._handlers = {}; // 'noteon' and 'noteoff' handlers. + this._now = null; // A cached current-time value. + if (isAudioPresent()) { + this.silence(); // Initializes top-level audio node. + } + } + + Instrument.timeOffset = 0.0625;// Seconds to delay all audiable timing. + Instrument.dequeueTime = 0.5; // Seconds before an event to reexamine queue. + Instrument.bufferSecs = 2; // Seconds ahead to put notes in WebAudio. + Instrument.toneLength = 1; // Default duration of a tone. + Instrument.cleanupDelay = 0.1; // Silent time before disconnecting nodes. + + // Sets the default timbre for the instrument. See defaultTimbre. + Instrument.prototype.setTimbre = function(t) { + this._timbre = makeTimbre(t, this._atop); // Saves a copy. + }; + + // Returns the default timbre for the instrument as an object. + Instrument.prototype.getTimbre = function(t) { + return makeTimbre(this._timbre, this._atop); // Makes a copy. + }; + + // Sets the overall volume for the instrument immediately. + Instrument.prototype.setVolume = function(v) { + // Without an audio system, volume cannot be set. + if (!this._out) { return; } + if (!isNaN(v)) { + this._out.gain.value = v; + } + }; + + // Sets the overall volume for the instrument. + Instrument.prototype.getVolume = function(v) { + // Without an audio system, volume is stuck at zero. + if (!this._out) { return 0.0; } + return this._out.gain.value; + }; + + // Silences the instrument immediately by reinitializing the audio + // graph for this instrument and emptying or flushing all queues in the + // scheduler. Carefully notifies all notes that have started but not + // yet finished, and sequences that are awaiting scheduled callbacks. + // Does not notify notes that have not yet started. + Instrument.prototype.silence = function() { + var j, finished, callbacks, initvolume = 1; + + // Clear future notes. + this._queue.length = 0; + this._minQueueTime = Infinity; + this._maxScheduledTime = 0; + + // Don't notify notes that haven't started yet. + this._startSet.length = 0; + + // Flush finish callbacks that are promised. + finished = this._finishSet; + this._finishSet = {}; + + // Flush one-time callacks that are promised. + callbacks = this._callbackSet; + this._callbackSet = []; + + // Disconnect the audio graph for this instrument. + if (this._out) { + this._out.disconnect(); + initvolume = this._out.gain.value; + } + + // Reinitialize the audio graph: all audio for the instrument + // multiplexes through a single gain node with a master volume. + this._atop = getAudioTop(); + this._out = this._atop.ac.createGain(); + this._out.gain.value = initvolume; + this._out.connect(this._atop.out); + + // As a last step, call all promised notifications. + for (j in finished) { this._trigger('noteoff', finished[j]); } + for (j = 0; j < callbacks.length; ++j) { callbacks[j].callback(); } + }; + + // Future notes are scheduled relative to now(), which provides + // access to audioCurrentStartTime(), a time that holds steady + // until the script releases to the event loop. When _now is + // non-null, it indicates that scheduling is already in progress. + // The timer-driven _doPoll function clears the cached _now. + Instrument.prototype.now = function() { + if (this._now != null) { + return this._now; + } + this._startPollTimer(true); // passing (true) sets this._now. + return this._now; + }; + + // Register an event handler. Done without jQuery to reduce dependencies. + Instrument.prototype.on = function(eventname, cb) { + if (!this._handlers.hasOwnProperty(eventname)) { + this._handlers[eventname] = []; + } + this._handlers[eventname].push(cb); + }; + + // Unregister an event handler. Done without jQuery to reduce dependencies. + Instrument.prototype.off = function(eventname, cb) { + if (this._handlers.hasOwnProperty(eventname)) { + if (!cb) { + this._handlers[eventname] = []; + } else { + var j, hunt = this._handlers[eventname]; + for (j = 0; j < hunt.length; ++j) { + if (hunt[j] === cb) { + hunt.splice(j, 1); + j -= 1; + } + } + } + } + }; + + // Trigger an event, notifying any registered handlers. + Instrument.prototype._trigger = function(eventname, record) { + var cb = this._handlers[eventname], j; + if (!cb) { return; } + if (cb.length == 1) { + // Special, common case of one handler: no copy needed. + cb[0](record); + return; + } + // Copy the array of callbacks before iterating, because the + // main this._handlers copy could be changed by a handler. + // You get notified if-and-only-if you are registered + // at the starting moment of _trigger. + cb = cb.slice(); + for (j = 0; j < cb.length; ++j) { + cb[j](record); + } + }; + + // Tells the WebAudio API to play a tone (now or soon). The passed + // record specifies a start time and release time, an ADSR envelope, + // and other timbre parameters. This function sets up a WebAudio + // node graph for the tone generators and filters for the tone. + Instrument.prototype._makeSound = function(record) { + var timbre = record.timbre || this._timbre, + starttime = record.time + Instrument.timeOffset, + releasetime = starttime + record.duration, + attacktime = Math.min(releasetime, starttime + timbre.attack), + decaytime = timbre.decay * + Math.pow(440 / record.frequency, timbre.decayfollow), + decaystarttime = attacktime, + stoptime = releasetime + timbre.release, + doubled = timbre.detune && timbre.detune != 1.0, + amp = timbre.gain * record.velocity * (doubled ? 0.5 : 1.0), + ac = this._atop.ac, + g, f, o, o2, pwave, k, wf, bwf; + // Only hook up tone generators if it is an audible sound. + if (record.duration > 0 && record.velocity > 0) { + g = ac.createGain(); + g.gain.setValueAtTime(0, starttime); + g.gain.linearRampToValueAtTime(amp, attacktime); + // For the beginning of the decay, use linearRampToValue instead + // of setTargetAtTime, because it avoids http://crbug.com/254942. + while (decaystarttime < attacktime + 1/32 && + decaystarttime + 1/256 < releasetime) { + // Just trace out the curve in increments of 1/256 sec + // for up to 1/32 seconds. + decaystarttime += 1/256; + g.gain.linearRampToValueAtTime( + amp * (timbre.sustain + (1 - timbre.sustain) * + Math.exp((attacktime - decaystarttime) / decaytime)), + decaystarttime); + } + // For the rest of the decay, use setTargetAtTime. + g.gain.setTargetAtTime(amp * timbre.sustain, + decaystarttime, decaytime); + // Then at release time, mark the value and ramp to zero. + g.gain.setValueAtTime(amp * (timbre.sustain + (1 - timbre.sustain) * + Math.exp((attacktime - releasetime) / decaytime)), releasetime); + g.gain.linearRampToValueAtTime(0, stoptime); + g.connect(this._out); + // Hook up a low-pass filter if cutoff is specified. + if ((!timbre.cutoff && !timbre.cutfollow) || timbre.cutoff == Infinity) { + f = g; + } else { + // Apply the cutoff frequency adjusted using cutfollow. + f = ac.createBiquadFilter(); + f.frequency.value = + timbre.cutoff + record.frequency * timbre.cutfollow; + f.Q.value = timbre.resonance; + f.connect(g); + } + // Hook up the main oscillator. + o = makeOscillator(this._atop, timbre.wave, record.frequency); + o.connect(f); + o.start(starttime); + o.stop(stoptime); + // Hook up a detuned oscillator. + if (doubled) { + o2 = makeOscillator( + this._atop, timbre.wave, record.frequency * timbre.detune); + o2.connect(f); + o2.start(starttime); + o2.stop(stoptime); + } + // Store nodes in the record so that they can be modified + // in case the tone is truncated later. + record.gainNode = g; + record.oscillators = [o]; + if (doubled) { record.oscillators.push(o2); } + record.cleanuptime = stoptime; + } else { + // Inaudible sounds are scheduled: their purpose is to truncate + // audible tones at the same pitch. But duration is set to zero + // so that they are cleaned up quickly. + record.duration = 0; + } + this._startSet.push(record); + }; + // Truncates a sound previously scheduled by _makeSound by using + // cancelScheduledValues and directly ramping down to zero. + // Can only be used to shorten a sound. + Instrument.prototype._truncateSound = function(record, truncatetime) { + if (truncatetime < record.time + record.duration) { + record.duration = Math.max(0, truncatetime - record.time); + if (record.gainNode) { + var timbre = record.timbre || this._timbre, + starttime = record.time + Instrument.timeOffset, + releasetime = truncatetime + Instrument.timeOffset, + attacktime = Math.min(releasetime, starttime + timbre.attack), + decaytime = timbre.decay * + Math.pow(440 / record.frequency, timbre.decayfollow), + stoptime = releasetime + timbre.release, + cleanuptime = stoptime + Instrument.cleanupDelay, + doubled = timbre.detune && timbre.detune != 1.0, + amp = timbre.gain * record.velocity * (doubled ? 0.5 : 1.0), + j, g = record.gainNode; + // Cancel any envelope points after the new releasetime. + g.gain.cancelScheduledValues(releasetime); + if (releasetime <= starttime) { + // Release before start? Totally silence the note. + g.gain.setValueAtTime(0, releasetime); + } else if (releasetime <= attacktime) { + // Release before attack is done? Interrupt ramp up. + g.gain.linearRampToValueAtTime( + amp * (releasetime - starttime) / (attacktime - starttime), + releasetime); + } else { + // Release during decay? Interrupt decay down. + g.gain.setValueAtTime(amp * (timbre.sustain + (1 - timbre.sustain) * + Math.exp((attacktime - releasetime) / decaytime)), releasetime); + } + // Then ramp down to zero according to record.release. + g.gain.linearRampToValueAtTime(0, stoptime); + // After stoptime, stop the oscillators. This is necessary to + // eliminate extra work for WebAudio for no-longer-audible notes. + if (record.oscillators) { + for (j = 0; j < record.oscillators.length; ++j) { + record.oscillators[j].stop(stoptime); + } + } + // Schedule disconnect. + record.cleanuptime = cleanuptime; + } + } + }; + // The core scheduling loop is managed by Instrument._doPoll. It reads + // the audiocontext's current time and pushes tone records from one + // stage to the next. + // + // 1. The first stage is the _queue, which has tones that have not + // yet been given to WebAudio. This loop scans _queue to find + // notes that need to begin in the next few seconds; then it + // sends those to WebAduio and moves them to _startSet. Because + // scheduled songs can be long, _queue can be large. + // + // 2. Second is _startSet, which has tones that have been given to + // WebAudio, but whose start times have not yet elapsed. When + // the time advances past the start time of a record, a 'noteon' + // notification is fired for the tone, and it is moved to + // _finishSet. + // + // 3. _finishSet represents the notes that are currently sounding. + // The programming model for Instrument is that only one tone of + // a specific frequency may be played at once within a Instrument, + // so only one tone of a given frequency may exist in _finishSet + // at once. When there is a conflict, the sooner-to-end-note + // is truncated. + // + // 4. After a note is released, it may have a litle release time + // (depending on timbre.release), after which the nodes can + // be totally disconnected and cleaned up. _cleanupSet holds + // notes for which we are awaiting cleanup. + Instrument.prototype._doPoll = function() { + this._pollTimer = null; + this._now = null; + if (interrupted) { + this.silence(); + return; + } + // The shortest time we can delay is 1 / 1000 secs, so if an event + // is within the next 0.5 ms, now is the closest moment, and we go + // ahead and process it. + var instant = this._atop.ac.currentTime + (1 / 2000), + callbacks = [], + j, work, when, freq, record, conflict, save, cb; + // Schedule a batch of notes + if (this._minQueueTime - instant <= Instrument.bufferSecs) { + if (this._unsortedQueue) { + this._queue.sort(function(a, b) { + if (a.time != b.time) { return a.time - b.time; } + if (a.duration != b.duration) { return a.duration - b.duration; } + return a.frequency - b.frequency; + }); + this._unsortedQueue = false; + } + for (j = 0; j < this._queue.length; ++j) { + if (this._queue[j].time - instant > Instrument.bufferSecs) { break; } + } + if (j > 0) { + work = this._queue.splice(0, j); + for (j = 0; j < work.length; ++j) { + this._makeSound(work[j]); + } + this._minQueueTime = + (this._queue.length > 0) ? this._queue[0].time : Infinity; + } + } + // Disconnect notes from the cleanup set. + for (j = 0; j < this._cleanupSet.length; ++j) { + record = this._cleanupSet[j]; + if (record.cleanuptime < instant) { + if (record.gainNode) { + // This explicit disconnect is needed or else Chrome's WebAudio + // starts getting overloaded after a couple thousand notes. + record.gainNode.disconnect(); + record.gainNode = null; + } + this._cleanupSet.splice(j, 1); + j -= 1; + } + } + // Notify about any notes finishing. + for (freq in this._finishSet) { + record = this._finishSet[freq]; + when = record.time + record.duration; + if (when <= instant) { + callbacks.push({ + order: [when, 0], + f: this._trigger, t: this, a: ['noteoff', record]}); + if (record.cleanuptime != Infinity) { + this._cleanupSet.push(record); + } + delete this._finishSet[freq]; + } + } + // Call any specific one-time callbacks that were registered. + for (j = 0; j < this._callbackSet.length; ++j) { + cb = this._callbackSet[j]; + when = cb.time; + if (when <= instant) { + callbacks.push({ + order: [when, 1], + f: cb.callback, t: null, a: []}); + this._callbackSet.splice(j, 1); + j -= 1; + } + } + // Notify about any notes starting. + for (j = 0; j < this._startSet.length; ++j) { + if (this._startSet[j].time <= instant) { + save = record = this._startSet[j]; + freq = record.frequency; + conflict = null; + if (this._finishSet.hasOwnProperty(freq)) { + // If there is already a note at the same frequency playing, + // then release the one that starts first, immediately. + conflict = this._finishSet[freq]; + if (conflict.time < record.time || (conflict.time == record.time && + conflict.duration < record.duration)) { + // Our new sound conflicts with an old one: end the old one + // and notify immediately of its noteoff event. + this._truncateSound(conflict, record.time); + callbacks.push({ + order: [record.time, 0], + f: this._trigger, t: this, a: ['noteoff', conflict]}); + delete this._finishSet[freq]; + } else { + // A conflict from the future has already scheduled, + // so our own note shouldn't sound. Truncate ourselves + // immediately, and suppress our own noteon and noteoff. + this._truncateSound(record, conflict.time); + conflict = record; + } + } + this._startSet.splice(j, 1); + j -= 1; + if (record.duration > 0 && record.velocity > 0 && + conflict !== record) { + this._finishSet[freq] = record; + callbacks.push({ + order: [record.time, 2], + f: this._trigger, t: this, a: ['noteon', record]}); + } + } + } + // Schedule the next _doPoll. + this._startPollTimer(); + + // Sort callbacks according to the "order" tuple, so earlier events + // are notified first. + callbacks.sort(function(a, b) { + if (a.order[0] != b.order[0]) { return a.order[0] - b.order[0]; } + // tiebreak by notifying 'noteoff' first and 'noteon' last. + return a.order[1] - b.order[1]; + }); + // At the end, call all the callbacks without depending on "this" state. + for (j = 0; j < callbacks.length; ++j) { + cb = callbacks[j]; + cb.f.apply(cb.t, cb.a); + } + }; + // Schedules the next _doPoll call by examining times in the various + // sets and determining the soonest event that needs _doPoll processing. + Instrument.prototype._startPollTimer = function(setnow) { + // If we have already done a "setnow", then pollTimer is zero-timeout + // and cannot be faster. + if (this._pollTimer && this._now != null) { + return; + } + var self = this, + poll = function() { self._doPoll(); }, + earliest = Infinity, j, delay; + if (this._pollTimer) { + // Clear any old timer + clearTimeout(this._pollTimer); + this._pollTimer = null; + } + if (setnow) { + // When scheduling tones, cache _now and keep a zero-timeout poll. + // _now will be cleared the next time we execute _doPoll. + this._now = audioCurrentStartTime(); + this._pollTimer = setTimeout(poll, 0); + return; + } + // Timer due to notes starting: wake up for 'noteon' notification. + for (j = 0; j < this._startSet.length; ++j) { + earliest = Math.min(earliest, this._startSet[j].time); + } + // Timer due to notes finishing: wake up for 'noteoff' notification. + for (j in this._finishSet) { + earliest = Math.min( + earliest, this._finishSet[j].time + this._finishSet[j].duration); + } + // Timer due to scheduled callback. + for (j = 0; j < this._callbackSet.length; ++j) { + earliest = Math.min(earliest, this._callbackSet[j].time); + } + // Timer due to cleanup: add a second to give some time to batch up. + if (this._cleanupSet.length > 0) { + earliest = Math.min(earliest, this._cleanupSet[0].cleanuptime + 1); + } + // Timer due to sequencer events: subtract a little time to stay ahead. + earliest = Math.min( + earliest, this._minQueueTime - Instrument.dequeueTime); + + delay = Math.max(0.001, earliest - this._atop.ac.currentTime); + + // If there are no future events, then we do not need a timer. + if (isNaN(delay) || delay == Infinity) { return; } + + // Use the Javascript timer to wake up at the right moment. + this._pollTimer = setTimeout(poll, Math.round(delay * 1000)); + }; + + // The low-level tone function. + Instrument.prototype.tone = + function(pitch, duration, velocity, delay, timbre, origin) { + // If audio is not present, this is a no-op. + if (!this._atop) { return; } + + // Called with an object instead of listed args. + if (typeof(pitch) == 'object') { + if (velocity == null) velocity = pitch.velocity; + if (duration == null) duration = pitch.duration; + if (delay == null) delay = pitch.delay; + if (timbre == null) timbre = pitch.timbre; + if (origin == null) origin = pitch.origin; + pitch = pitch.pitch; + } + + // Convert pitch from various formats to Hz frequency and a midi num. + var midi, frequency; + if (!pitch) { pitch = 'C'; } + if (isNaN(pitch)) { + midi = pitchToMidi(pitch); + frequency = midiToFrequency(midi); + } else { + frequency = Number(pitch); + if (frequency < 0) { + midi = -frequency; + frequency = midiToFrequency(midi); + } else { + midi = frequencyToMidi(frequency); + } + } + + if (!timbre) { + timbre = this._timbre; + } + // If there is a custom timbre, validate and copy it. + if (timbre !== this._timbre) { + var given = timbre, key; + timbre = {} + for (key in defaultTimbre) { + if (key in given) { + timbre[key] = given[key]; + } else { + timbre[key] = defaultTimbre[key]; + } + } + } + + // Create the record for a tone. + var ac = this._atop.ac, + now = this.now(), + time = now + (delay || 0), + record = { + time: time, + on: false, + frequency: frequency, + midi: midi, + velocity: (velocity == null ? 1 : velocity), + duration: (duration == null ? Instrument.toneLength : duration), + timbre: timbre, + instrument: this, + gainNode: null, + oscillators: null, + cleanuptime: Infinity, + origin: origin // save the origin of the tone for visible feedback + }; + + if (time < now + Instrument.bufferSecs) { + // The tone starts soon! Give it directly to WebAudio. + this._makeSound(record); + } else { + // The tone is later: queue it. + if (!this._unsortedQueue && this._queue.length && + time < this._queue[this._queue.length -1].time) { + this._unsortedQueue = true; + } + this._queue.push(record); + this._minQueueTime = Math.min(this._minQueueTime, record.time); + } + }; + // The low-level callback scheduling method. + Instrument.prototype.schedule = function(delay, callback) { + this._callbackSet.push({ time: this.now() + delay, callback: callback }); + }; + // The high-level sequencing method. + Instrument.prototype.play = function(abcstring) { + var args = Array.prototype.slice.call(arguments), + done = null, + opts = {}, subfile, + abcfile, argindex, tempo, timbre, k, delay, maxdelay = 0, attenuate, + voicename, stems, ni, vn, j, stem, note, beatsecs, secs, v, files = []; + // Look for continuation as last argument. + if (args.length && 'function' == typeof(args[args.length - 1])) { + done = args.pop(); + } + if (!this._atop) { + if (done) { done(); } + return; + } + // Look for options as first object. + argindex = 0; + if ('object' == typeof(args[0])) { + // Copy own properties into an options object. + for (k in args[0]) if (args[0].hasOwnProperty(k)) { + opts[k] = args[0][k]; + } + argindex = 1; + // If a song is supplied by options object, process it. + if (opts.song) { + args.push(opts.song); + } + } + // Parse any number of ABC files as input. + for (; argindex < args.length; ++argindex) { + // Handle splitting of ABC subfiles at X: lines. + subfile = args[argindex].split(/\n(?=X:)/); + for (k = 0; k < subfile.length; ++k) { + abcfile = parseABCFile(subfile[k]); + if (!abcfile) continue; + // Take tempo markings from the first file, and share them. + if (!opts.tempo && abcfile.tempo) { + opts.tempo = abcfile.tempo; + if (abcfile.unitbeat) { + opts.tempo *= abcfile.unitbeat / (abcfile.unitnote || 1); + } + } + // Ignore files without songs. + if (!abcfile.voice) continue; + files.push(abcfile); + } + } + // Default tempo to 120 if nothing else is specified. + if (!opts.tempo) { opts.tempo = 120; } + // Default volume to 1 if nothing is specified. + if (opts.volume == null) { opts.volume = 1; } + beatsecs = 60.0 / opts.tempo; + // Schedule all notes from all the files. + for (k = 0; k < files.length; ++k) { + abcfile = files[k]; + // Each file can have multiple voices (e.g., left and right hands) + for (vn in abcfile.voice) { + // Each voice could have a separate timbre. + timbre = makeTimbre(opts.timbre || abcfile.voice[vn].timbre || + abcfile.timbre || this._timbre, this._atop); + // Each voice has a series of stems (notes or chords). + stems = abcfile.voice[vn].stems; + if (!stems) continue; + // Starting at delay zero (now), schedule all tones. + delay = 0; + for (ni = 0; ni < stems.length; ++ni) { + stem = stems[ni]; + // Attenuate chords to reduce clipping. + attenuate = 1 / Math.sqrt(stem.notes.length); + // Schedule every note inside a stem. + for (j = 0; j < stem.notes.length; ++j) { + note = stem.notes[j]; + if (note.holdover) { + // Skip holdover notes from ties. + continue; + } + secs = (note.time || stem.time) * beatsecs; + if (stem.staccato) { + // Shorten staccato notes. + secs = Math.min(Math.min(secs, beatsecs / 16), + timbre.attack + timbre.decay); + } else if (!note.slurred && secs >= 1/8) { + // Separate unslurred notes by about a 30th of a second. + secs -= 1/32; + } + v = (note.velocity || 1) * attenuate * opts.volume; + // This is innsermost part of the inner loop! + this.tone( // Play the tone: + note.pitch, // at the given pitch + secs, // for the given duration + v, // with the given volume + delay, // starting at the proper time + timbre, // with the selected timbre + note // the origin object for visual feedback + ); + } + delay += stem.time * beatsecs; // Advance the sequenced time. + } + maxdelay = Math.max(delay, maxdelay); + } + } + this._maxScheduledTime = + Math.max(this._maxScheduledTime, this.now() + maxdelay); + if (done) { + // Schedule a "done" callback after all sequencing is complete. + this.schedule(maxdelay, done); + } + }; + + + // The default sound is a square wave with a pretty quick decay to zero. + var defaultTimbre = Instrument.defaultTimbre = { + wave: 'square', // Oscillator type. + gain: 0.1, // Overall gain at maximum attack. + attack: 0.002, // Attack time at the beginning of a tone. + decay: 0.4, // Rate of exponential decay after attack. + decayfollow: 0, // Amount of decay shortening for higher notes. + sustain: 0, // Portion of gain to sustain indefinitely. + release: 0.1, // Release time after a tone is done. + cutoff: 0, // Low-pass filter cutoff frequency. + cutfollow: 0, // Cutoff adjustment, a multiple of oscillator freq. + resonance: 0, // Low-pass filter resonance. + detune: 0 // Detune factor for a second oscillator. + }; + + // Norrmalizes a timbre object by making a copy that has exactly + // the right set of timbre fields, defaulting when needed. + // A timbre can specify any of the fields of defaultTimbre; any + // unspecified fields are treated as they are set in defaultTimbre. + function makeTimbre(options, atop) { + if (!options) { + options = {}; + } + if (typeof(options) == 'string') { + // Abbreviation: name a wave to get a default timbre for that wave. + options = { wave: options }; + } + var result = {}, key, + wt = atop && atop.wavetable && atop.wavetable[options.wave]; + for (key in defaultTimbre) { + if (options.hasOwnProperty(key)) { + result[key] = options[key]; + } else if (wt && wt.defs && wt.defs.hasOwnProperty(key)) { + result[key] = wt.defs[key]; + } else{ + result[key] = defaultTimbre[key]; + } + } + return result; + } + + function getWhiteNoiseBuf() { + var ac = getAudioTop().ac, + bufferSize = 2 * ac.sampleRate, + whiteNoiseBuf = ac.createBuffer(1, bufferSize, ac.sampleRate), + output = whiteNoiseBuf.getChannelData(0); + for (var i = 0; i < bufferSize; i++) { + output[i] = Math.random() * 2 - 1; + } + return whiteNoiseBuf; + } + + // This utility function creates an oscillator at the given frequency + // and the given wavename. It supports lookups in a static wavetable, + // defined right below. + function makeOscillator(atop, wavename, freq) { + if (wavename == 'noise') { + var whiteNoise = atop.ac.createBufferSource(); + whiteNoise.buffer = getWhiteNoiseBuf(); + whiteNoise.loop = true; + return whiteNoise; + } + var wavetable = atop.wavetable, o = atop.ac.createOscillator(), + k, pwave, bwf, wf; + try { + if (wavetable.hasOwnProperty(wavename)) { + // Use a customized wavetable. + pwave = wavetable[wavename].wave; + if (wavetable[wavename].freq) { + bwf = 0; + // Look for a higher-frequency variant. + for (k in wavetable[wavename].freq) { + wf = Number(k); + if (freq > wf && wf > bwf) { + bwf = wf; + pwave = wavetable[wavename].freq[bwf]; + } + } + } + if (!o.setPeriodicWave && o.setWaveTable) { + // The old API name: Safari 7 still uses this. + o.setWaveTable(pwave); + } else { + // The new API name. + o.setPeriodicWave(pwave); + } + } else { + o.type = wavename; + } + } catch(e) { + if (window.console) { window.console.log(e); } + // If unrecognized, just use square. + // TODO: support "noise" or other wave shapes. + o.type = 'square'; + } + o.frequency.value = freq; + return o; + } + + // Accepts either an ABC pitch or a midi number and converts to midi. + Instrument.pitchToMidi = function(n) { + if (typeof(n) == 'string') { return pitchToMidi(n); } + return n; + } + + // Accepts either an ABC pitch or a midi number and converts to ABC pitch. + Instrument.midiToPitch = function(n) { + if (typeof(n) == 'number') { return midiToPitch(n); } + return n; + } + + return Instrument; +})(); + +// Parses an ABC file to an object with the following structure: +// { +// X: value from the X: lines in header (\n separated for multiple values) +// V: value from the V:myname lines that appear before K: +// (etc): for all the one-letter header-names. +// K: value from the K: lines in header. +// tempo: Q: line parsed as beatsecs +// timbre: ... I:timbre line as parsed by makeTimbre +// voice: { +// myname: { // voice with id "myname" +// V: value from the V:myname lines (from the body) +// stems: [...] as parsed by parseABCstems +// } +// } +// } +// ABC files are idiosyncratic to parse: the written specifications +// do not necessarily reflect the defacto standard implemented by +// ABC content on the web. This implementation is designed to be +// practical, working on content as it appears on the web, and only +// using the written standard as a guideline. +var ABCheader = /^([A-Za-z]):\s*(.*)$/; +var ABCtoken = /(?:\[[A-Za-z]:[^\]]*\])|\s+|%[^\n]*|![^\s!:|\[\]]*!|\+[^+|!]*\+|[_<>@^]?"[^"]*"|\[|\]|>+|<+|(?:(?:\^+|_+|=|)[A-Ga-g](?:,+|'+|))|\(\d+(?::\d+){0,2}|\d*\/\d+|\d+\/?|\/+|[xzXZ]|\[?\|\]?|:?\|:?|::|./g; +function parseABCFile(str) { + var lines = str.split('\n'), + result = {}, + context = result, timbre, + j, k, header, stems, key = {}, accent = { slurred: 0 }, voiceid, out; + // ABC files are parsed one line at a time. + for (j = 0; j < lines.length; ++j) { + // First, check to see if the line is a header line. + header = ABCheader.exec(lines[j]); + if (header) { + handleInformation(header[1], header[2].trim()); + } else if (/^\s*(?:%.*)?$/.test(lines[j])) { + // Skip blank and comment lines. + continue; + } else { + // Parse the notes. + parseABCNotes(lines[j]); + } + } + var infer = ['unitnote', 'unitbeat', 'tempo']; + if (result.voice) { + out = []; + for (j in result.voice) { + if (result.voice[j].stems && result.voice[j].stems.length) { + // Calculate times for all the tied notes. This happens at the end + // because in principle, the first note of a song could be tied all + // the way through to the last note. + processTies(result.voice[j].stems); + // Bring up inferred tempo values from voices if not specified + // in the header. + for (k = 0; k < infer.length; ++k) { + if (!(infer[k] in result) && (infer[k] in result.voice[j])) { + result[infer[k]] = result.voice[j][infer[k]]; + } + } + // Remove this internal state variable; + delete result.voice[j].accent; + } else { + out.push(j); + } + } + // Delete any voices that had no stems. + for (j = 0; j < out.length; ++j) { + delete result.voice[out[j]]; + } + } + return result; + + + //////////////////////////////////////////////////////////////////////// + // Parsing helper functions below. + //////////////////////////////////////////////////////////////////////// + + + // Processes header fields such as V: voice, which may appear at the + // top of the ABC file, or in the ABC body in a [V:voice] directive. + function handleInformation(field, value) { + // The following headers are recognized and processed. + switch(field) { + case 'V': + // A V: header switches voices if in the body. + // If in the header, then it is just advisory. + if (context !== result) { + startVoiceContext(value.split(' ')[0]); + } + break; + case 'M': + parseMeter(value, context); + break; + case 'L': + parseUnitNote(value, context); + break; + case 'Q': + parseTempo(value, context); + break; + } + // All headers (including unrecognized ones) are + // just accumulated as properties. Repeated header + // lines are accumulated as multiline properties. + if (context.hasOwnProperty(field)) { + context[field] += '\n' + value; + } else { + context[field] = value; + } + // The K header is special: it should be the last one + // before the voices and notes begin. + if (field == 'K') { + key = keysig(value); + if (context === result) { + startVoiceContext(firstVoiceName()); + } + } + } + + // Shifts context to a voice with the given id given. If no id + // given, then just sticks with the current voice. If the current + // voice is unnamed and empty, renames the current voice. + function startVoiceContext(id) { + id = id || ''; + if (!id && context !== result) { + return; + } + if (!result.voice) { + result.voice = {}; + } + if (result.voice.hasOwnProperty(id)) { + // Resume a named voice. + context = result.voice[id]; + accent = context.accent; + } else { + // Start a new voice. + context = { id: id, accent: { slurred: 0 } }; + result.voice[id] = context; + accent = context.accent; + } + } + + // For picking a default voice, looks for the first voice name. + function firstVoiceName() { + if (result.V) { + return result.V.split(/\s+/)[0]; + } else { + return ''; + } + } + + // Parses a single line of ABC notes (i.e., not a header line). + // + // We process an ABC song stream by dividing it into tokens, each of + // which is a pitch, duration, or special decoration symbol; then + // we process each decoration individually, and we process each + // stem as a group using parseStem. + // The structure of a single ABC note is something like this: + // + // NOTE -> STACCATO? PITCH DURATION? TIE? + // + // I.e., it always has a pitch, and it is prefixed by some optional + // decorations such as a (.) staccato marking, and it is suffixed by + // an optional duration and an optional tie (-) marking. + // + // A stem is either a note or a bracketed series of notes, followed + // by duration and tie. + // + // STEM -> NOTE OR '[' NOTE * ']' DURAITON? TIE? + // + // Then a song is just a sequence of stems interleaved with other + // decorations such as dynamics markings and measure delimiters. + function parseABCNotes(str) { + var tokens = str.match(ABCtoken), parsed = null, + index = 0, dotted = 0, beatlet = null, t; + if (!tokens) { + return null; + } + while (index < tokens.length) { + // Ignore %comments and !markings! + if (/^[\s%]/.test(tokens[index])) { index++; continue; } + // Handle inline [X:...] information fields + if (/^\[[A-Za-z]:[^\]]*\]$/.test(tokens[index])) { + handleInformation( + tokens[index].substring(1, 2), + tokens[index].substring(3, tokens[index].length - 1).trim() + ); + index++; + continue; + } + // Handled dotted notation abbreviations. + if (//.test(tokens[index])) { + dotted = tokens[index++].length; + continue; + } + if (/^\(\d+(?::\d+)*/.test(tokens[index])) { + beatlet = parseBeatlet(tokens[index++]); + continue; + } + if (/^[!+].*[!+]$/.test(tokens[index])) { + parseDecoration(tokens[index++], accent); + continue; + } + if (/^.?".*"$/.test(tokens[index])) { + // Ignore double-quoted tokens (chords and general text annotations). + index++; + continue; + } + if (/^[()]$/.test(tokens[index])) { + if (tokens[index++] == '(') { + accent.slurred += 1; + } else { + accent.slurred -= 1; + if (accent.slurred <= 0) { + accent.slurred = 0; + if (context.stems && context.stems.length >= 1) { + // The last notes in a slur are not slurred. + slurStem(context.stems[context.stems.length - 1], false); + } + } + } + continue; + } + // Handle measure markings by clearing accidentals. + if (/\|/.test(tokens[index])) { + for (t in accent) { + if (t.length == 1) { + // Single-letter accent properties are note accidentals. + delete accent[t]; + } + } + index++; + continue; + } + parsed = parseStem(tokens, index, key, accent); + // Skip unparsable bits + if (parsed === null) { + index++; + continue; + } + // Process a parsed stem. + if (beatlet) { + scaleStem(parsed.stem, beatlet.time); + beatlet.count -= 1; + if (!beatlet.count) { + beatlet = null; + } + } + // If syncopated with > or < notation, shift part of a beat + // between this stem and the previous one. + if (dotted && context.stems && context.stems.length) { + if (dotted > 0) { + t = (1 - Math.pow(0.5, dotted)) * parsed.stem.time; + } else { + t = (Math.pow(0.5, -dotted) - 1) * + context.stems[context.stems.length - 1].time; + } + syncopateStem(context.stems[context.stems.length - 1], t); + syncopateStem(parsed.stem, -t); + } + dotted = 0; + // Slur all the notes contained within a strem. + if (accent.slurred) { + slurStem(parsed.stem, true); + } + // Start a default voice if we're not in a voice yet. + if (context === result) { + startVoiceContext(firstVoiceName()); + } + if (!('stems' in context)) { context.stems = []; } + // Add the stem to the sequence of stems for this voice. + context.stems.push(parsed.stem); + // Advance the parsing index since a stem is multiple tokens. + index = parsed.index; + } + } + + // Parse M: lines. "3/4" is 3/4 time and "C" is 4/4 (common) time. + function parseMeter(mline, beatinfo) { + var d = /^C/.test(mline) ? 4/4 : durationToTime(mline); + if (!d) { return; } + if (!beatinfo.unitnote) { + if (d < 0.75) { + beatinfo.unitnote = 1/16; + } else { + beatinfo.unitnote = 1/8; + } + } + } + // Parse L: lines, e.g., "1/8". + function parseUnitNote(lline, beatinfo) { + var d = durationToTime(lline); + if (!d) { return; } + beatinfo.unitnote = d; + } + // Parse Q: line, e.g., "1/4=66". + function parseTempo(qline, beatinfo) { + var parts = qline.split(/\s+|=/), j, unit = null, tempo = null; + for (j = 0; j < parts.length; ++j) { + // It could be reversed, like "66=1/4", or just "120", so + // determine what is going on by looking for a slash etc. + if (parts[j].indexOf('/') >= 0 || /^[1-4]$/.test(parts[j])) { + // The note-unit (e.g., 1/4). + unit = unit || durationToTime(parts[j]); + } else { + // The tempo-number (e.g., 120) + tempo = tempo || Number(parts[j]); + } + } + if (unit) { + beatinfo.unitbeat = unit; + } + if (tempo) { + beatinfo.tempo = tempo; + } + } + // Run through all the notes, adding up time for tied notes, + // and marking notes that were held over with holdover = true. + function processTies(stems) { + var tied = {}, nextTied, j, k, note, firstNote; + for (j = 0; j < stems.length; ++j) { + nextTied = {}; + for (k = 0; k < stems[j].notes.length; ++k) { + firstNote = note = stems[j].notes[k]; + if (tied.hasOwnProperty(note.pitch)) { // Pitch was tied from before. + firstNote = tied[note.pitch]; // Get the earliest note in the tie. + firstNote.time += note.time; // Extend its time. + note.holdover = true; // Silence this note as a holdover. + } + if (note.tie) { // This note is tied with the next. + nextTied[note.pitch] = firstNote; // Save it away. + } + } + tied = nextTied; + } + } + // Returns a map of A-G -> accidentals, according to the key signature. + // When n is zero, there are no accidentals (e.g., C major or A minor). + // When n is positive, there are n sharps (e.g., for G major, n = 1). + // When n is negative, there are -n flats (e.g., for F major, n = -1). + function accidentals(n) { + var sharps = 'FCGDAEB', + result = {}, j; + if (!n) { + return result; + } + if (n > 0) { // Handle sharps. + for (j = 0; j < n && j < 7; ++j) { + result[sharps.charAt(j)] = '^'; + } + } else { // Flats are in the opposite order. + for (j = 0; j > n && j > -7; --j) { + result[sharps.charAt(6 + j)] = '_'; + } + } + return result; + } + // Decodes the key signature line (e.g., K: C#m) at the front of an ABC tune. + // Supports the whole range of scale systems listed in the ABC spec. + function keysig(keyname) { + if (!keyname) { return {}; } + var kkey, sigcodes = { + // Major + 'c#':7, 'f#':6, 'b':5, 'e':4, 'a':3, 'd':2, 'g':1, 'c':0, + 'f':-1, 'bb':-2, 'eb':-3, 'ab':-4, 'db':-5, 'gb':-6, 'cb':-7, + // Minor + 'a#m':7, 'd#m':6, 'g#m':5, 'c#m':4, 'f#m':3, 'bm':2, 'em':1, 'am':0, + 'dm':-1, 'gm':-2, 'cm':-3, 'fm':-4, 'bbm':-5, 'ebm':-6, 'abm':-7, + // Mixolydian + 'g#mix':7, 'c#mix':6, 'f#mix':5, 'bmix':4, 'emix':3, + 'amix':2, 'dmix':1, 'gmix':0, 'cmix':-1, 'fmix':-2, + 'bbmix':-3, 'ebmix':-4, 'abmix':-5, 'dbmix':-6, 'gbmix':-7, + // Dorian + 'd#dor':7, 'g#dor':6, 'c#dor':5, 'f#dor':4, 'bdor':3, + 'edor':2, 'ador':1, 'ddor':0, 'gdor':-1, 'cdor':-2, + 'fdor':-3, 'bbdor':-4, 'ebdor':-5, 'abdor':-6, 'dbdor':-7, + // Phrygian + 'e#phr':7, 'a#phr':6, 'd#phr':5, 'g#phr':4, 'c#phr':3, + 'f#phr':2, 'bphr':1, 'ephr':0, 'aphr':-1, 'dphr':-2, + 'gphr':-3, 'cphr':-4, 'fphr':-5, 'bbphr':-6, 'ebphr':-7, + // Lydian + 'f#lyd':7, 'blyd':6, 'elyd':5, 'alyd':4, 'dlyd':3, + 'glyd':2, 'clyd':1, 'flyd':0, 'bblyd':-1, 'eblyd':-2, + 'ablyd':-3, 'dblyd':-4, 'gblyd':-5, 'cblyd':-6, 'fblyd':-7, + // Locrian + 'b#loc':7, 'e#loc':6, 'a#loc':5, 'd#loc':4, 'g#loc':3, + 'c#loc':2, 'f#loc':1, 'bloc':0, 'eloc':-1, 'aloc':-2, + 'dloc':-3, 'gloc':-4, 'cloc':-5, 'floc':-6, 'bbloc':-7 + }; + var k = keyname.replace(/\s+/g, '').toLowerCase().substr(0, 5); + var scale = k.match(/maj|min|mix|dor|phr|lyd|loc|m/); + if (scale) { + if (scale == 'maj') { + kkey = k.substr(0, scale.index); + } else if (scale == 'min') { + kkey = k.substr(0, scale.index + 1); + } else { + kkey = k.substr(0, scale.index + scale[0].length); + } + } else { + kkey = /^[a-g][#b]?/.exec(k) || ''; + } + var result = accidentals(sigcodes[kkey]); + var extras = keyname.substr(kkey.length).match(/(_+|=|\^+)[a-g]/ig); + if (extras) { + for (var j = 0; j < extras.length; ++j) { + var note = extras[j].charAt(extras[j].length - 1).toUpperCase(); + if (extras[j].charAt(0) == '=') { + delete result[note]; + } else { + result[note] = extras[j].substr(0, extras[j].length - 1); + } + } + } + return result; + } + // Additively adjusts the beats for a stem and the contained notes. + function syncopateStem(stem, t) { + var j, note, stemtime = stem.time, newtime = stemtime + t; + stem.time = newtime; + for (j = 0; j < stem.notes.length; ++j) { + note = stem.notes[j]; + // Only adjust a note's duration if it matched the stem's duration. + if (note.time == stemtime) { note.time = newtime; } + } + } + // Marks everything in the stem with the slur attribute (or deletes it). + function slurStem(stem, addSlur) { + var j, note; + for (j = 0; j < stem.notes.length; ++j) { + note = stem.notes[j]; + if (addSlur) { + note.slurred = true; + } else if (note.slurred) { + delete note.slurred; + } + } + } + // Scales the beats for a stem and the contained notes. + function scaleStem(stem, s) { + var j; + stem.time *= s; + for (j = 0; j < stem.notes.length; ++j) { + stem.notes[j].time *= s;; + } + } + // Parses notation of the form (3 or (5:2:10, which means to do + // the following 3 notes in the space of 2 notes, or to do the following + // 10 notes at the rate of 5 notes per 2 beats. + function parseBeatlet(token) { + var m = /^\((\d+)(?::(\d+)(?::(\d+))?)?$/.exec(token); + if (!m) { return null; } + var count = Number(m[1]), + beats = Number(m[2]) || 2, + duration = Number(m[3]) || count; + return { + time: beats / count, + count: duration + }; + } + // Parse !ppp! markings. + function parseDecoration(token, accent) { + if (token.length < 2) { return; } + token = token.substring(1, token.length - 1); + switch (token) { + case 'pppp': case 'ppp': + accent.dynamics = 0.2; break; + case 'pp': + accent.dynamics = 0.4; break; + case 'p': + accent.dynamics = 0.6; break; + case 'mp': + accent.dynamics = 0.8; break; + case 'mf': + accent.dynamics = 1.0; break; + case 'f': + accent.dynamics = 1.2; break; + case 'ff': + accent.dynamics = 1.4; break; + case 'fff': case 'ffff': + accent.dynamics = 1.5; break; + } + } + // Parses a stem, which may be a single note, or which may be + // a chorded note. + function parseStem(tokens, index, key, accent) { + var notes = [], + duration = '', staccato = false, + noteDuration, noteTime, velocity, + lastNote = null, minStemTime = Infinity, j; + // A single staccato marking applies to the entire stem. + if (index < tokens.length && '.' == tokens[index]) { + staccato = true; + index++; + } + if (index < tokens.length && tokens[index] == '[') { + // Deal with [CEG] chorded notation. + index++; + // Scan notes within the chord. + while (index < tokens.length) { + // Ignore and space and %comments. + if (/^[\s%]/.test(tokens[index])) { + index++; + continue; + } + if (/[A-Ga-g]/.test(tokens[index])) { + // Grab a pitch. + lastNote = { + pitch: applyAccent(tokens[index++], key, accent), + tie: false + } + lastNote.frequency = pitchToFrequency(lastNote.pitch); + notes.push(lastNote); + } else if (/[xzXZ]/.test(tokens[index])) { + // Grab a rest. + lastNote = null; + index++; + } else if ('.' == tokens[index]) { + // A staccato mark applies to the entire stem. + staccato = true; + index++; + continue; + } else { + // Stop parsing the stem if something is unrecognized. + break; + } + // After a pitch or rest, look for a duration. + if (index < tokens.length && + /^(?![\s%!]).*[\d\/]/.test(tokens[index])) { + noteDuration = tokens[index++]; + noteTime = durationToTime(noteDuration); + } else { + noteDuration = ''; + noteTime = 1; + } + // If it's a note (not a rest), store the duration + if (lastNote) { + lastNote.duration = noteDuration; + lastNote.time = noteTime; + } + // When a stem has more than one duration, use the shortest + // one for timing. The standard says to pick the first one, + // but in practice, transcribed music online seems to + // follow the rule that the stem's duration is determined + // by the shortest contained duration. + if (noteTime && noteTime < minStemTime) { + duration = noteDuration; + minStemTime = noteTime; + } + // After a duration, look for a tie mark. Individual notes + // within a stem can be tied. + if (index < tokens.length && '-' == tokens[index]) { + if (lastNote) { + notes[notes.length - 1].tie = true; + } + index++; + } + } + // The last thing in a chord should be a ]. If it isn't, then + // this doesn't look like a stem after all, and return null. + if (tokens[index] != ']') { + return null; + } + index++; + } else if (index < tokens.length && /[A-Ga-g]/.test(tokens[index])) { + // Grab a single note. + lastNote = { + pitch: applyAccent(tokens[index++], key, accent), + tie: false, + duration: '', + time: 1 + } + lastNote.frequency = pitchToFrequency(lastNote.pitch); + notes.push(lastNote); + } else if (index < tokens.length && /^[xzXZ]$/.test(tokens[index])) { + // Grab a rest - no pitch. + index++; + } else { + // Something we don't recognize - not a stem. + return null; + } + // Right after a [chord], note, or rest, look for a duration marking. + if (index < tokens.length && /^(?![\s%!]).*[\d\/]/.test(tokens[index])) { + duration = tokens[index++]; + noteTime = durationToTime(duration); + // Apply the duration to all the ntoes in the stem. + // NOTE: spec suggests multiplying this duration, but that + // idiom is not seen (so far) in practice. + for (j = 0; j < notes.length; ++j) { + notes[j].duration = duration; + notes[j].time = noteTime; + } + } + // Then look for a trailing tie marking. Will tie every note in a chord. + if (index < tokens.length && '-' == tokens[index]) { + index++; + for (j = 0; j < notes.length; ++j) { + notes[j].tie = true; + } + } + if (accent.dynamics) { + velocity = accent.dynamics; + for (j = 0; j < notes.length; ++j) { + notes[j].velocity = velocity; + } + } + return { + index: index, + stem: { + notes: notes, + duration: duration, + staccato: staccato, + time: durationToTime(duration) + } + }; + } + // Normalizes pitch markings by stripping leading = if present. + function stripNatural(pitch) { + if (pitch.length > 0 && pitch.charAt(0) == '=') { + return pitch.substr(1); + } + return pitch; + } + // Processes an accented pitch, automatically applying accidentals + // that have accumulated within the measure, and also saving + // explicit accidentals to continue to apply in the measure. + function applyAccent(pitch, key, accent) { + var m = /^(\^+|_+|=|)([A-Ga-g])(.*)$/.exec(pitch), letter; + if (!m) { return pitch; } + // Note that an accidental in one octave applies in other octaves. + letter = m[2].toUpperCase(); + if (m[1].length > 0) { + // When there is an explicit accidental, then remember it for + // the rest of the measure. + accent[letter] = m[1]; + return stripNatural(pitch); + } + if (accent.hasOwnProperty(letter)) { + // Accidentals from this measure apply to unaccented notes. + return stripNatural(accent[letter] + m[2] + m[3]); + } + if (key.hasOwnProperty(letter)) { + // Key signatures apply by default. + return stripNatural(key[letter] + m[2] + m[3]); + } + return stripNatural(pitch); + } + // Converts an ABC duration to a number (e.g., "/3"->0.333 or "11/2"->1.5). + function durationToTime(duration) { + var m = /^(\d*)(?:\/(\d*))?$|^(\/+)$/.exec(duration), n, d, i = 0, ilen; + if (!m) return; + if (m[3]) return Math.pow(0.5, m[3].length); + d = (m[2] ? parseFloat(m[2]) : /\//.test(duration) ? 2 : 1); + // Handle mixed frations: + ilen = 0; + n = (m[1] ? parseFloat(m[1]) : 1); + if (m[2]) { + while (ilen + 1 < m[1].length && n > d) { + ilen += 1 + i = parseFloat(m[1].substring(0, ilen)) + n = parseFloat(m[1].substring(ilen)) + } + } + return i + (n / d); + } +} + +// wavetable is a table of names for nonstandard waveforms. +// The table maps names to objects that have wave: and freq: +// properties. The wave: property is a PeriodicWave to use +// for the oscillator. The freq: property, if present, +// is a map from higher frequencies to more PeriodicWave +// objects; when a frequency higher than the given threshold +// is requested, the alternate PeriodicWave is used. +function makeWavetable(ac) { + return (function(wavedata) { + function makePeriodicWave(data) { + var n = data.real.length, + real = new Float32Array(n), + imag = new Float32Array(n), + j; + for (j = 0; j < n; ++j) { + real[j] = data.real[j]; + imag[j] = data.imag[j]; + } + try { + // Latest API naming. + return ac.createPeriodicWave(real, imag); + } catch (e) { } + try { + // Earlier API naming. + return ac.createWaveTable(real, imag); + } catch (e) { } + return null; + } + function makeMultiple(data, mult, amt) { + var result = { real: [], imag: [] }, j, n = data.real.length, m; + for (j = 0; j < n; ++j) { + m = Math.log(mult[Math.min(j, mult.length - 1)]); + result.real.push(data.real[j] * Math.exp(amt * m)); + result.imag.push(data.imag[j] * Math.exp(amt * m)); + } + return result; + } + var result = {}, k, d, n, j, ff, record, wave, pw; + for (k in wavedata) { + d = wavedata[k]; + wave = makePeriodicWave(d); + if (!wave) { continue; } + record = { wave: wave }; + // A strategy for computing higher frequency waveforms: apply + // multipliers to each harmonic according to d.mult. These + // multipliers can be interpolated and applied at any number + // of transition frequencies. + if (d.mult) { + ff = wavedata[k].freq; + record.freq = {}; + for (j = 0; j < ff.length; ++j) { + wave = + makePeriodicWave(makeMultiple(d, d.mult, (j + 1) / ff.length)); + if (wave) { record.freq[ff[j]] = wave; } + } + } + // This wave has some default filter settings. + if (d.defs) { + record.defs = d.defs; + } + result[k] = record; + } + return result; + })({ + // Currently the only nonstandard waveform is "piano". + // It is based on the first 32 harmonics from the example: + // https://github.com/GoogleChrome/web-audio-samples + // /blob/gh-pages/samples/audio/wave-tables/Piano + // That is a terrific sound for the lowest piano tones. + // For higher tones, interpolate to a customzed wave + // shape created by hand, and apply a lowpass filter. + piano: { + real: [0, 0, -0.203569, 0.5, -0.401676, 0.137128, -0.104117, 0.115965, + -0.004413, 0.067884, -0.00888, 0.0793, -0.038756, 0.011882, + -0.030883, 0.027608, -0.013429, 0.00393, -0.014029, 0.00972, + -0.007653, 0.007866, -0.032029, 0.046127, -0.024155, 0.023095, + -0.005522, 0.004511, -0.003593, 0.011248, -0.004919, 0.008505], + imag: [0, 0.147621, 0, 0.000007, -0.00001, 0.000005, -0.000006, 0.000009, + 0, 0.000008, -0.000001, 0.000014, -0.000008, 0.000003, + -0.000009, 0.000009, -0.000005, 0.000002, -0.000007, 0.000005, + -0.000005, 0.000005, -0.000023, 0.000037, -0.000021, 0.000022, + -0.000006, 0.000005, -0.000004, 0.000014, -0.000007, 0.000012], + // How to adjust the harmonics for the higest notes. + mult: [1, 1, 0.18, 0.016, 0.01, 0.01, 0.01, 0.004, + 0.014, 0.02, 0.014, 0.004, 0.002, 0.00001], + // The frequencies at which to interpolate the harmonics. + freq: [65, 80, 100, 135, 180, 240, 620, 1360], + // The default filter settings to use for the piano wave. + // TODO: this approach attenuates low notes too much - + // this should be fixed. + defs: { wave: 'piano', gain: 0.5, + attack: 0.002, decay: 0.25, sustain: 0.03, release: 0.1, + decayfollow: 0.7, + cutoff: 800, cutfollow: 0.1, resonance: 1, detune: 0.9994 } + } + }); +} + +// End of musical.js copy. + + +////////////////////////////////////////////////////////////////////////// +// SYNC, REMOVE SUPPORT +// sync() function +////////////////////////////////////////////////////////////////////////// + +function gatherelts(args) { + var elts = [], j, argcount = args.length, completion; + // The optional last argument is a callback when the sync is triggered. + if (argcount && $.isFunction(args[argcount - 1])) { + completion = args[--argcount]; + } + // Gather elements passed as arguments. + for (j = 0; j < argcount; ++j) { + if (!args[j]) { + continue; // Skip null args. + } else if (args[j].constructor === $) { + elts.push.apply(elts, args[j].toArray()); // Unpack jQuery. + } else if ($.isArray(args[j])) { + elts.push.apply(elts, args[j]); // Accept an array. + } else { + elts.push(args[j]); // Individual elements. + } + } + return { + elts: $.unique(elts), // Remove duplicates. + completion: completion + }; +} + +function sync() { + var a = gatherelts(arguments), + elts = a.elts, completion = a.completion, j, ready = []; + function proceed() { + var cb = ready, j; + ready = null; + // Call completion before unblocking animation. + if (completion) { completion(); } + // Unblock all animation queues. + for (j = 0; j < cb.length; ++j) { cb[j](); } + } + if (elts.length > 1) for (j = 0; j < elts.length; ++j) { + queueWaitIfLoadingImg(elts[j]); + $(elts[j]).queue(function(next) { + if (ready) { + ready.push(next); + if (ready.length == elts.length) { + proceed(); + } + } + }); + } +} + +function remove() { + var a = gatherelts(arguments), + elts = a.elts, completion = a.completion, j, count = elts.length; + for (j = 0; j < elts.length; ++j) { + $(elts[j]).queue(function(next) { + $(this).remove(); + count -= 1; + if (completion && count == 0) { completion(); } + next(); + }); + } +} + +////////////////////////////////////////////////////////////////////////// +// JQUERY REGISTRATION +// Register all our hooks. +////////////////////////////////////////////////////////////////////////// + +$.extend(true, $, { + cssHooks: { + turtlePenStyle: makePenStyleHook(), + turtlePenDown: makePenDownHook(), + turtleSpeed: makeTurtleSpeedHook(), + turtleEasing: makeTurtleEasingHook(), + turtleForward: makeTurtleForwardHook(), + turtleTurningRadius: makeTurningRadiusHook(), + turtlePosition: makeTurtleXYHook('turtlePosition', 'tx', 'ty', true), + turtlePositionX: makeTurtleHook('tx', parseFloat, 'px', true), + turtlePositionY: makeTurtleHook('ty', parseFloat, 'px', true), + turtleRotation: makeTurtleHook('rot', maybeArcRotation, 'deg', true), + turtleScale: makeTurtleXYHook('turtleScale', 'sx', 'sy', false), + turtleScaleX: makeTurtleHook('sx', identity, '', false), + turtleScaleY: makeTurtleHook('sy', identity, '', false), + turtleTwist: makeTurtleHook('twi', normalizeRotation, 'deg', false), + turtleHull: makeHullHook(), + turtleTimbre: makeTimbreHook(), + turtleVolume: makeVolumeHook() + }, + cssNumber: { + turtleRotation: true, + turtleSpeed: true, turtleScale: true, turtleScaleX: true, turtleScaleY: true, @@ -2537,10 +5667,10 @@ $.extend(true, $.fx, { } }); -function wraphelp(text, fn) { - fn.helptext = text; - return fn; -} +////////////////////////////////////////////////////////////////////////// +// FUNCTION WRAPPERS +// Wrappers for all API functions +////////////////////////////////////////////////////////////////////////// function helpwrite(text) { see.html('