From 3d710e092da1da37c902b9923d13e1ecf20f3673 Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Wed, 14 Nov 2012 17:49:28 +0000 Subject: [PATCH 1/6] Initial attempt at a sandboxed live preview frame. This commit adds live-preview-sandbox.js, which is a drop-in replacement for live-preview.js. It creates the preview frame in a separate origin and communicates with friendlycode through postmessage. This is done primarily to prevent malicious code from compromising the embedder. Right now this doesn't work with the preview-to-editor-mapping component, but it can be made to. I originally tried using the "sandbox" iframe attribute, which is supported on webkit and bleeding-edge versions of firefox, but as child iframes inherit the properties of their sandboxed parent, this proved problematic. So I just assume that the preview iframe is in a separate origin; by default, if no alternate origin is passed in, the code assumes it's at port+1 (i.e., if the friendlycode instance is at localhost:8001, the sandboxed iframe is created at localhost:8002). This is currently inconvenient to set up because it requires running two web servers on different ports. --- js/fc/ui/live-preview-sandbox.js | 84 ++++ js/jschannel.js | 614 ++++++++++++++++++++++++++++ js/require-config.js | 3 + templates/live-preview-sandbox.html | 60 +++ 4 files changed, 761 insertions(+) create mode 100644 js/fc/ui/live-preview-sandbox.js create mode 100644 js/jschannel.js create mode 100644 templates/live-preview-sandbox.html diff --git a/js/fc/ui/live-preview-sandbox.js b/js/fc/ui/live-preview-sandbox.js new file mode 100644 index 0000000..9158823 --- /dev/null +++ b/js/fc/ui/live-preview-sandbox.js @@ -0,0 +1,84 @@ +"use strict"; + +// Displays the HTML source of a CodeMirror editor as a rendered preview +// in an iframe. +define(function(require) { + var $ = require("jquery"), + BackboneEvents = require("backbone-events"), + Channel = require("jschannel"); + + function LivePreviewSandbox(options) { + var self = {codeMirror: options.codeMirror, title: ""}, + codeMirror = options.codeMirror, + sandboxURL = options.sandboxURL, + readyToSendLatestReparse = false, + iframeSandbox, + channel, + latestReparse; + + if (!sandboxURL) + (function() { + var baseURL = require.toUrl("templates/live-preview-sandbox.html"); + var a = document.createElement('a'); + a.setAttribute("href", baseURL); + var oldPort = location.port; + var newPort = (parseInt(oldPort) + 1).toString(); + sandboxURL = a.href.replace(":" + oldPort + "/", + ":" + newPort + "/"); + })(); + + function sendLatestReparse() { + channel.call({ + method: "setHTML", + params: { + error: latestReparse.error, + sourceCode: latestReparse.sourceCode + }, + error: function(e) { + if (window.console) + window.console.log("setHTML() error", e); + readyToSendLatestReparse = true; + }, + success: function(v) { + readyToSendLatestReparse = true; + } + }); + } + + function setupIframeSandbox() { + iframeSandbox = document.createElement("iframe"); + iframeSandbox.setAttribute("src", sandboxURL); + options.previewArea.append(iframeSandbox); + channel = Channel.build({ + window: iframeSandbox.contentWindow, + origin: "*", + scope: "friendlycode", + onReady: sendLatestReparse + }); + channel.bind("change:title", function(trans, title) { + self.trigger("change:title", title); + }); + } + + codeMirror.on("reparse", function(event) { + var isPreviewInDocument = $.contains(document.documentElement, + options.previewArea[0]); + if (!isPreviewInDocument) { + if (window.console) + window.console.log("reparse triggered, but preview area is not " + + "attached to the document."); + return; + } + if (!iframeSandbox) + setupIframeSandbox(); + latestReparse = event; + if (readyToSendLatestReparse) + sendLatestReparse(); + }); + + BackboneEvents.mixin(self); + return self; + }; + + return LivePreviewSandbox; +}); diff --git a/js/jschannel.js b/js/jschannel.js new file mode 100644 index 0000000..d821167 --- /dev/null +++ b/js/jschannel.js @@ -0,0 +1,614 @@ +/* + * js_channel is a very lightweight abstraction on top of + * postMessage which defines message formats and semantics + * to support interactions more rich than just message passing + * js_channel supports: + * + query/response - traditional rpc + * + query/update/response - incremental async return of results + * to a query + * + notifications - fire and forget + * + error handling + * + * js_channel is based heavily on json-rpc, but is focused at the + * problem of inter-iframe RPC. + * + * Message types: + * There are 5 types of messages that can flow over this channel, + * and you may determine what type of message an object is by + * examining its parameters: + * 1. Requests + * + integer id + * + string method + * + (optional) any params + * 2. Callback Invocations (or just "Callbacks") + * + integer id + * + string callback + * + (optional) params + * 3. Error Responses (or just "Errors) + * + integer id + * + string error + * + (optional) string message + * 4. Responses + * + integer id + * + (optional) any result + * 5. Notifications + * + string method + * + (optional) any params + */ + +;var Channel = (function() { + "use strict"; + + // current transaction id, start out at a random *odd* number between 1 and a million + // There is one current transaction counter id per page, and it's shared between + // channel instances. That means of all messages posted from a single javascript + // evaluation context, we'll never have two with the same id. + var s_curTranId = Math.floor(Math.random()*1000001); + + // no two bound channels in the same javascript evaluation context may have the same origin, scope, and window. + // futher if two bound channels have the same window and scope, they may not have *overlapping* origins + // (either one or both support '*'). This restriction allows a single onMessage handler to efficiently + // route messages based on origin and scope. The s_boundChans maps origins to scopes, to message + // handlers. Request and Notification messages are routed using this table. + // Finally, channels are inserted into this table when built, and removed when destroyed. + var s_boundChans = { }; + + // add a channel to s_boundChans, throwing if a dup exists + function s_addBoundChan(win, origin, scope, handler) { + function hasWin(arr) { + for (var i = 0; i < arr.length; i++) if (arr[i].win === win) return true; + return false; + } + + // does she exist? + var exists = false; + + + if (origin === '*') { + // we must check all other origins, sadly. + for (var k in s_boundChans) { + if (!s_boundChans.hasOwnProperty(k)) continue; + if (k === '*') continue; + if (typeof s_boundChans[k][scope] === 'object') { + exists = hasWin(s_boundChans[k][scope]); + if (exists) break; + } + } + } else { + // we must check only '*' + if ((s_boundChans['*'] && s_boundChans['*'][scope])) { + exists = hasWin(s_boundChans['*'][scope]); + } + if (!exists && s_boundChans[origin] && s_boundChans[origin][scope]) + { + exists = hasWin(s_boundChans[origin][scope]); + } + } + if (exists) throw "A channel is already bound to the same window which overlaps with origin '"+ origin +"' and has scope '"+scope+"'"; + + if (typeof s_boundChans[origin] != 'object') s_boundChans[origin] = { }; + if (typeof s_boundChans[origin][scope] != 'object') s_boundChans[origin][scope] = [ ]; + s_boundChans[origin][scope].push({win: win, handler: handler}); + } + + function s_removeBoundChan(win, origin, scope) { + var arr = s_boundChans[origin][scope]; + for (var i = 0; i < arr.length; i++) { + if (arr[i].win === win) { + arr.splice(i,1); + } + } + if (s_boundChans[origin][scope].length === 0) { + delete s_boundChans[origin][scope]; + } + } + + function s_isArray(obj) { + if (Array.isArray) return Array.isArray(obj); + else { + return (obj.constructor.toString().indexOf("Array") != -1); + } + } + + // No two outstanding outbound messages may have the same id, period. Given that, a single table + // mapping "transaction ids" to message handlers, allows efficient routing of Callback, Error, and + // Response messages. Entries are added to this table when requests are sent, and removed when + // responses are received. + var s_transIds = { }; + + // class singleton onMessage handler + // this function is registered once and all incoming messages route through here. This + // arrangement allows certain efficiencies, message data is only parsed once and dispatch + // is more efficient, especially for large numbers of simultaneous channels. + var s_onMessage = function(e) { + try { + var m = JSON.parse(e.data); + if (typeof m !== 'object' || m === null) throw "malformed"; + } catch(e) { + // just ignore any posted messages that do not consist of valid JSON + return; + } + + var w = e.source; + var o = e.origin; + var s, i, meth; + + if (typeof m.method === 'string') { + var ar = m.method.split('::'); + if (ar.length == 2) { + s = ar[0]; + meth = ar[1]; + } else { + meth = m.method; + } + } + + if (typeof m.id !== 'undefined') i = m.id; + + // w is message source window + // o is message origin + // m is parsed message + // s is message scope + // i is message id (or undefined) + // meth is unscoped method name + // ^^ based on these factors we can route the message + + // if it has a method it's either a notification or a request, + // route using s_boundChans + if (typeof meth === 'string') { + var delivered = false; + if (s_boundChans[o] && s_boundChans[o][s]) { + for (var j = 0; j < s_boundChans[o][s].length; j++) { + if (s_boundChans[o][s][j].win === w) { + s_boundChans[o][s][j].handler(o, meth, m); + delivered = true; + break; + } + } + } + + if (!delivered && s_boundChans['*'] && s_boundChans['*'][s]) { + for (var j = 0; j < s_boundChans['*'][s].length; j++) { + if (s_boundChans['*'][s][j].win === w) { + s_boundChans['*'][s][j].handler(o, meth, m); + break; + } + } + } + } + // otherwise it must have an id (or be poorly formed + else if (typeof i != 'undefined') { + if (s_transIds[i]) s_transIds[i](o, meth, m); + } + }; + + // Setup postMessage event listeners + if (window.addEventListener) window.addEventListener('message', s_onMessage, false); + else if(window.attachEvent) window.attachEvent('onmessage', s_onMessage); + + /* a messaging channel is constructed from a window and an origin. + * the channel will assert that all messages received over the + * channel match the origin + * + * Arguments to Channel.build(cfg): + * + * cfg.window - the remote window with which we'll communicate + * cfg.origin - the expected origin of the remote window, may be '*' + * which matches any origin + * cfg.scope - the 'scope' of messages. a scope string that is + * prepended to message names. local and remote endpoints + * of a single channel must agree upon scope. Scope may + * not contain double colons ('::'). + * cfg.debugOutput - A boolean value. If true and window.console.log is + * a function, then debug strings will be emitted to that + * function. + * cfg.debugOutput - A boolean value. If true and window.console.log is + * a function, then debug strings will be emitted to that + * function. + * cfg.postMessageObserver - A function that will be passed two arguments, + * an origin and a message. It will be passed these immediately + * before messages are posted. + * cfg.gotMessageObserver - A function that will be passed two arguments, + * an origin and a message. It will be passed these arguments + * immediately after they pass scope and origin checks, but before + * they are processed. + * cfg.onReady - A function that will be invoked when a channel becomes "ready", + * this occurs once both sides of the channel have been + * instantiated and an application level handshake is exchanged. + * the onReady function will be passed a single argument which is + * the channel object that was returned from build(). + */ + return { + build: function(cfg) { + var debug = function(m) { + if (cfg.debugOutput && window.console && window.console.log) { + // try to stringify, if it doesn't work we'll let javascript's built in toString do its magic + try { if (typeof m !== 'string') m = JSON.stringify(m); } catch(e) { } + console.log("["+chanId+"] " + m); + } + }; + + /* browser capabilities check */ + if (!window.postMessage) throw("jschannel cannot run this browser, no postMessage"); + if (!window.JSON || !window.JSON.stringify || ! window.JSON.parse) { + throw("jschannel cannot run this browser, no JSON parsing/serialization"); + } + + /* basic argument validation */ + if (typeof cfg != 'object') throw("Channel build invoked without a proper object argument"); + + if (!cfg.window || !cfg.window.postMessage) throw("Channel.build() called without a valid window argument"); + + /* we'd have to do a little more work to be able to run multiple channels that intercommunicate the same + * window... Not sure if we care to support that */ + if (window === cfg.window) throw("target window is same as present window -- not allowed"); + + // let's require that the client specify an origin. if we just assume '*' we'll be + // propagating unsafe practices. that would be lame. + var validOrigin = false; + if (typeof cfg.origin === 'string') { + var oMatch; + if (cfg.origin === "*") validOrigin = true; + // allow valid domains under http and https. Also, trim paths off otherwise valid origins. + else if (null !== (oMatch = cfg.origin.match(/^https?:\/\/(?:[-a-zA-Z0-9_\.])+(?::\d+)?/))) { + cfg.origin = oMatch[0].toLowerCase(); + validOrigin = true; + } + } + + if (!validOrigin) throw ("Channel.build() called with an invalid origin"); + + if (typeof cfg.scope !== 'undefined') { + if (typeof cfg.scope !== 'string') throw 'scope, when specified, must be a string'; + if (cfg.scope.split('::').length > 1) throw "scope may not contain double colons: '::'"; + } + + /* private variables */ + // generate a random and psuedo unique id for this channel + var chanId = (function () { + var text = ""; + var alpha = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + for(var i=0; i < 5; i++) text += alpha.charAt(Math.floor(Math.random() * alpha.length)); + return text; + })(); + + // registrations: mapping method names to call objects + var regTbl = { }; + // current oustanding sent requests + var outTbl = { }; + // current oustanding received requests + var inTbl = { }; + // are we ready yet? when false we will block outbound messages. + var ready = false; + var pendingQueue = [ ]; + + var createTransaction = function(id,origin,callbacks) { + var shouldDelayReturn = false; + var completed = false; + + return { + origin: origin, + invoke: function(cbName, v) { + // verify in table + if (!inTbl[id]) throw "attempting to invoke a callback of a nonexistent transaction: " + id; + // verify that the callback name is valid + var valid = false; + for (var i = 0; i < callbacks.length; i++) if (cbName === callbacks[i]) { valid = true; break; } + if (!valid) throw "request supports no such callback '" + cbName + "'"; + + // send callback invocation + postMessage({ id: id, callback: cbName, params: v}); + }, + error: function(error, message) { + completed = true; + // verify in table + if (!inTbl[id]) throw "error called for nonexistent message: " + id; + + // remove transaction from table + delete inTbl[id]; + + // send error + postMessage({ id: id, error: error, message: message }); + }, + complete: function(v) { + completed = true; + // verify in table + if (!inTbl[id]) throw "complete called for nonexistent message: " + id; + // remove transaction from table + delete inTbl[id]; + // send complete + postMessage({ id: id, result: v }); + }, + delayReturn: function(delay) { + if (typeof delay === 'boolean') { + shouldDelayReturn = (delay === true); + } + return shouldDelayReturn; + }, + completed: function() { + return completed; + } + }; + }; + + var setTransactionTimeout = function(transId, timeout, method) { + return window.setTimeout(function() { + if (outTbl[transId]) { + // XXX: what if client code raises an exception here? + var msg = "timeout (" + timeout + "ms) exceeded on method '" + method + "'"; + (1,outTbl[transId].error)("timeout_error", msg); + delete outTbl[transId]; + delete s_transIds[transId]; + } + }, timeout); + }; + + var onMessage = function(origin, method, m) { + // if an observer was specified at allocation time, invoke it + if (typeof cfg.gotMessageObserver === 'function') { + // pass observer a clone of the object so that our + // manipulations are not visible (i.e. method unscoping). + // This is not particularly efficient, but then we expect + // that message observers are primarily for debugging anyway. + try { + cfg.gotMessageObserver(origin, m); + } catch (e) { + debug("gotMessageObserver() raised an exception: " + e.toString()); + } + } + + // now, what type of message is this? + if (m.id && method) { + // a request! do we have a registered handler for this request? + if (regTbl[method]) { + var trans = createTransaction(m.id, origin, m.callbacks ? m.callbacks : [ ]); + inTbl[m.id] = { }; + try { + // callback handling. we'll magically create functions inside the parameter list for each + // callback + if (m.callbacks && s_isArray(m.callbacks) && m.callbacks.length > 0) { + for (var i = 0; i < m.callbacks.length; i++) { + var path = m.callbacks[i]; + var obj = m.params; + var pathItems = path.split('/'); + for (var j = 0; j < pathItems.length - 1; j++) { + var cp = pathItems[j]; + if (typeof obj[cp] !== 'object') obj[cp] = { }; + obj = obj[cp]; + } + obj[pathItems[pathItems.length - 1]] = (function() { + var cbName = path; + return function(params) { + return trans.invoke(cbName, params); + }; + })(); + } + } + var resp = regTbl[method](trans, m.params); + if (!trans.delayReturn() && !trans.completed()) trans.complete(resp); + } catch(e) { + // automagic handling of exceptions: + var error = "runtime_error"; + var message = null; + // * if it's a string then it gets an error code of 'runtime_error' and string is the message + if (typeof e === 'string') { + message = e; + } else if (typeof e === 'object') { + // either an array or an object + // * if it's an array of length two, then array[0] is the code, array[1] is the error message + if (e && s_isArray(e) && e.length == 2) { + error = e[0]; + message = e[1]; + } + // * if it's an object then we'll look form error and message parameters + else if (typeof e.error === 'string') { + error = e.error; + if (!e.message) message = ""; + else if (typeof e.message === 'string') message = e.message; + else e = e.message; // let the stringify/toString message give us a reasonable verbose error string + } + } + + // message is *still* null, let's try harder + if (message === null) { + try { + message = JSON.stringify(e); + /* On MSIE8, this can result in 'out of memory', which + * leaves message undefined. */ + if (typeof(message) == 'undefined') + message = e.toString(); + } catch (e2) { + message = e.toString(); + } + } + + trans.error(error,message); + } + } + } else if (m.id && m.callback) { + if (!outTbl[m.id] ||!outTbl[m.id].callbacks || !outTbl[m.id].callbacks[m.callback]) + { + debug("ignoring invalid callback, id:"+m.id+ " (" + m.callback +")"); + } else { + // XXX: what if client code raises an exception here? + outTbl[m.id].callbacks[m.callback](m.params); + } + } else if (m.id) { + if (!outTbl[m.id]) { + debug("ignoring invalid response: " + m.id); + } else { + // XXX: what if client code raises an exception here? + if (m.error) { + (1,outTbl[m.id].error)(m.error, m.message); + } else { + if (m.result !== undefined) (1,outTbl[m.id].success)(m.result); + else (1,outTbl[m.id].success)(); + } + delete outTbl[m.id]; + delete s_transIds[m.id]; + } + } else if (method) { + // tis a notification. + if (regTbl[method]) { + // yep, there's a handler for that. + // transaction is null for notifications. + regTbl[method](null, m.params); + // if the client throws, we'll just let it bubble out + // what can we do? Also, here we'll ignore return values + } + } + }; + + // now register our bound channel for msg routing + s_addBoundChan(cfg.window, cfg.origin, ((typeof cfg.scope === 'string') ? cfg.scope : ''), onMessage); + + // scope method names based on cfg.scope specified when the Channel was instantiated + var scopeMethod = function(m) { + if (typeof cfg.scope === 'string' && cfg.scope.length) m = [cfg.scope, m].join("::"); + return m; + }; + + // a small wrapper around postmessage whose primary function is to handle the + // case that clients start sending messages before the other end is "ready" + var postMessage = function(msg, force) { + if (!msg) throw "postMessage called with null message"; + + // delay posting if we're not ready yet. + var verb = (ready ? "post " : "queue "); + debug(verb + " message: " + JSON.stringify(msg)); + if (!force && !ready) { + pendingQueue.push(msg); + } else { + if (typeof cfg.postMessageObserver === 'function') { + try { + cfg.postMessageObserver(cfg.origin, msg); + } catch (e) { + debug("postMessageObserver() raised an exception: " + e.toString()); + } + } + + cfg.window.postMessage(JSON.stringify(msg), cfg.origin); + } + }; + + var onReady = function(trans, type) { + debug('ready msg received'); + if (ready) throw "received ready message while in ready state. help!"; + + if (type === 'ping') { + chanId += '-R'; + } else { + chanId += '-L'; + } + + obj.unbind('__ready'); // now this handler isn't needed any more. + ready = true; + debug('ready msg accepted.'); + + if (type === 'ping') { + obj.notify({ method: '__ready', params: 'pong' }); + } + + // flush queue + while (pendingQueue.length) { + postMessage(pendingQueue.pop()); + } + + // invoke onReady observer if provided + if (typeof cfg.onReady === 'function') cfg.onReady(obj); + }; + + var obj = { + // tries to unbind a bound message handler. returns false if not possible + unbind: function (method) { + if (regTbl[method]) { + if (!(delete regTbl[method])) throw ("can't delete method: " + method); + return true; + } + return false; + }, + bind: function (method, cb) { + if (!method || typeof method !== 'string') throw "'method' argument to bind must be string"; + if (!cb || typeof cb !== 'function') throw "callback missing from bind params"; + + if (regTbl[method]) throw "method '"+method+"' is already bound!"; + regTbl[method] = cb; + return this; + }, + call: function(m) { + if (!m) throw 'missing arguments to call function'; + if (!m.method || typeof m.method !== 'string') throw "'method' argument to call must be string"; + if (!m.success || typeof m.success !== 'function') throw "'success' callback missing from call"; + + // now it's time to support the 'callback' feature of jschannel. We'll traverse the argument + // object and pick out all of the functions that were passed as arguments. + var callbacks = { }; + var callbackNames = [ ]; + + var pruneFunctions = function (path, obj) { + if (typeof obj === 'object') { + for (var k in obj) { + if (!obj.hasOwnProperty(k)) continue; + var np = path + (path.length ? '/' : '') + k; + if (typeof obj[k] === 'function') { + callbacks[np] = obj[k]; + callbackNames.push(np); + delete obj[k]; + } else if (typeof obj[k] === 'object') { + pruneFunctions(np, obj[k]); + } + } + } + }; + pruneFunctions("", m.params); + + // build a 'request' message and send it + var msg = { id: s_curTranId, method: scopeMethod(m.method), params: m.params }; + if (callbackNames.length) msg.callbacks = callbackNames; + + if (m.timeout) + // XXX: This function returns a timeout ID, but we don't do anything with it. + // We might want to keep track of it so we can cancel it using clearTimeout() + // when the transaction completes. + setTransactionTimeout(s_curTranId, m.timeout, scopeMethod(m.method)); + + // insert into the transaction table + outTbl[s_curTranId] = { callbacks: callbacks, error: m.error, success: m.success }; + s_transIds[s_curTranId] = onMessage; + + // increment current id + s_curTranId++; + + postMessage(msg); + }, + notify: function(m) { + if (!m) throw 'missing arguments to notify function'; + if (!m.method || typeof m.method !== 'string') throw "'method' argument to notify must be string"; + + // no need to go into any transaction table + postMessage({ method: scopeMethod(m.method), params: m.params }); + }, + destroy: function () { + s_removeBoundChan(cfg.window, cfg.origin, ((typeof cfg.scope === 'string') ? cfg.scope : '')); + if (window.removeEventListener) window.removeEventListener('message', onMessage, false); + else if(window.detachEvent) window.detachEvent('onmessage', onMessage); + ready = false; + regTbl = { }; + inTbl = { }; + outTbl = { }; + cfg.origin = null; + pendingQueue = [ ]; + debug("channel destroyed"); + chanId = ""; + } + }; + + obj.bind('__ready', onReady); + setTimeout(function() { + postMessage({ method: scopeMethod('__ready'), params: "ping" }, true); + }, 0); + + return obj; + } + }; +})(); diff --git a/js/require-config.js b/js/require-config.js index cf386d9..3f74b00 100644 --- a/js/require-config.js +++ b/js/require-config.js @@ -28,6 +28,9 @@ var require = { return Backbone.noConflict(); } }, + jschannel: { + exports: "Channel" + }, codemirror: { exports: "CodeMirror" }, diff --git a/templates/live-preview-sandbox.html b/templates/live-preview-sandbox.html new file mode 100644 index 0000000..834c99b --- /dev/null +++ b/templates/live-preview-sandbox.html @@ -0,0 +1,60 @@ + + + +Live Preview Sandbox + + + From d3714382ae4d3d34b77a30e531277fdb9ee7134c Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Wed, 14 Nov 2012 19:00:46 +0000 Subject: [PATCH 2/6] allow a custom live preview implementation to be passed into Friendlycode. --- js/fc/ui/editor-panes.js | 3 ++- js/fc/ui/editor.js | 3 ++- js/friendlycode.js | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/js/fc/ui/editor-panes.js b/js/fc/ui/editor-panes.js index ca22dcf..0512287 100644 --- a/js/fc/ui/editor-panes.js +++ b/js/fc/ui/editor-panes.js @@ -20,6 +20,7 @@ define(function(require) { div = options.container, initialValue = options.value || "", allowJS = options.allowJS || false, + makeLivePreview = options.makeLivePreview || LivePreview, sourceCode = $('
').appendTo(div), previewArea = $('
').appendTo(div), helpArea = $('').appendTo(div), @@ -51,7 +52,7 @@ define(function(require) { errorArea: errorArea, relocator: relocator }); - var preview = self.preview = LivePreview({ + var preview = self.preview = makeLivePreview({ codeMirror: codeMirror, ignoreErrors: true, previewArea: previewArea diff --git a/js/fc/ui/editor.js b/js/fc/ui/editor.js index 983db1d..88e1379 100644 --- a/js/fc/ui/editor.js +++ b/js/fc/ui/editor.js @@ -15,7 +15,8 @@ define([ var panes = EditorPanes({ container: panesDiv, value: value, - allowJS: options.allowJS + allowJS: options.allowJS, + makeLivePreview: options.makeLivePreview }); var toolbar = EditorToolbar({ container: toolbarDiv, diff --git a/js/friendlycode.js b/js/friendlycode.js index 1a0e59d..cd6cdc7 100644 --- a/js/friendlycode.js +++ b/js/friendlycode.js @@ -20,7 +20,8 @@ define(function(require) { location.pathname + "#{{VIEW_URL}}", editor = Editor({ container: options.container, - allowJS: options.allowJS + allowJS: options.allowJS, + makeLivePreview: options.makeLivePreview }), ready = $.Deferred(); From 9afdf406020112509c5d8238632c1ac25aeed9f2 Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Wed, 14 Nov 2012 19:04:29 +0000 Subject: [PATCH 3/6] added sandboxed-alternate-publisher example. --- examples/alternate-publisher.html | 58 +-------------------- examples/hackpub.js | 55 +++++++++++++++++++ examples/sandboxed-alternate-publisher.html | 38 ++++++++++++++ 3 files changed, 94 insertions(+), 57 deletions(-) create mode 100644 examples/hackpub.js create mode 100644 examples/sandboxed-alternate-publisher.html diff --git a/examples/alternate-publisher.html b/examples/alternate-publisher.html index dd59eb5..c094e76 100644 --- a/examples/alternate-publisher.html +++ b/examples/alternate-publisher.html @@ -12,63 +12,7 @@ - + + + + + + From c7fb304f4f914c64de77a32ac0886b92e772cb98 Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Wed, 14 Nov 2012 19:09:13 +0000 Subject: [PATCH 4/6] typo fix --- examples/sandboxed-alternate-publisher.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/sandboxed-alternate-publisher.html b/examples/sandboxed-alternate-publisher.html index c953db8..1d1e092 100644 --- a/examples/sandboxed-alternate-publisher.html +++ b/examples/sandboxed-alternate-publisher.html @@ -4,7 +4,7 @@ - Alternate Publisher Friendlycode Editor + Sandboxed Alternate Publisher Friendlycode Editor From e8cb1cdc675cfe2145410bf87cd751ddd6170ca6 Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Thu, 15 Nov 2012 14:22:18 +0000 Subject: [PATCH 5/6] If no sandboxURL is passed in, just use one at our origin. --- js/fc/ui/live-preview-sandbox.js | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/js/fc/ui/live-preview-sandbox.js b/js/fc/ui/live-preview-sandbox.js index 9158823..23da9f8 100644 --- a/js/fc/ui/live-preview-sandbox.js +++ b/js/fc/ui/live-preview-sandbox.js @@ -17,15 +17,7 @@ define(function(require) { latestReparse; if (!sandboxURL) - (function() { - var baseURL = require.toUrl("templates/live-preview-sandbox.html"); - var a = document.createElement('a'); - a.setAttribute("href", baseURL); - var oldPort = location.port; - var newPort = (parseInt(oldPort) + 1).toString(); - sandboxURL = a.href.replace(":" + oldPort + "/", - ":" + newPort + "/"); - })(); + sandboxURL = require.toUrl("templates/live-preview-sandbox.html"); function sendLatestReparse() { channel.call({ From acb8608cf8017e11b04f72cb50e45e9fb5fe9cc5 Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Thu, 15 Nov 2012 14:27:07 -0500 Subject: [PATCH 6/6] made preview to editor mapping work w/ sandboxed preview. --- js/fc/ui/live-preview-sandbox.js | 14 ++++- js/fc/ui/preview-to-editor-mapping.js | 85 +++++++++++++-------------- templates/live-preview-sandbox.html | 5 ++ 3 files changed, 58 insertions(+), 46 deletions(-) diff --git a/js/fc/ui/live-preview-sandbox.js b/js/fc/ui/live-preview-sandbox.js index 23da9f8..d62669b 100644 --- a/js/fc/ui/live-preview-sandbox.js +++ b/js/fc/ui/live-preview-sandbox.js @@ -8,7 +8,12 @@ define(function(require) { Channel = require("jschannel"); function LivePreviewSandbox(options) { - var self = {codeMirror: options.codeMirror, title: ""}, + var self = { + codeMirror: options.codeMirror, + title: "", + inEditor: true, + channel: null + }, codeMirror = options.codeMirror, sandboxURL = options.sandboxURL, readyToSendLatestReparse = false, @@ -45,11 +50,16 @@ define(function(require) { window: iframeSandbox.contentWindow, origin: "*", scope: "friendlycode", - onReady: sendLatestReparse + onReady: function() { + sendLatestReparse(); + self.trigger("channel:ready", self); + } }); channel.bind("change:title", function(trans, title) { self.trigger("change:title", title); }); + self.channel = channel; + self.trigger("channel:created", self); } codeMirror.on("reparse", function(event) { diff --git a/js/fc/ui/preview-to-editor-mapping.js b/js/fc/ui/preview-to-editor-mapping.js index f5fc544..5984e08 100644 --- a/js/fc/ui/preview-to-editor-mapping.js +++ b/js/fc/ui/preview-to-editor-mapping.js @@ -20,8 +20,7 @@ define(["jquery", "./mark-tracker"], function($, MarkTracker) { return ' > ' + parts.join(' > '); } - function nodeToCode(node, docFrag) { - var parallelNode = getParallelNode(node, docFrag); + function nodeToCode(parallelNode) { var result = null; if (parallelNode) { var pi = parallelNode.parseInfo; @@ -35,62 +34,60 @@ define(["jquery", "./mark-tracker"], function($, MarkTracker) { return result; } - function getParallelNode(node, docFrag) { - var root, i; - var htmlNode = docFrag.querySelector("html"); - var origDocFrag = docFrag; - var parallelNode = null; - if (htmlNode && docFrag.querySelector("body")) { - root = node.ownerDocument.documentElement; - } else { - if (!htmlNode) { - docFrag = document.createDocumentFragment(); - htmlNode = document.createElement("html"); - docFrag.appendChild(htmlNode); - for (i = 0; i < origDocFrag.childNodes.length; i++) - htmlNode.appendChild(origDocFrag.childNodes[i]); - } - root = node.ownerDocument.body; - } - var path = "html " + pathTo(root, node); - parallelNode = docFrag.querySelector(path); - if (origDocFrag != docFrag) { - for (i = 0; i < htmlNode.childNodes.length; i++) - origDocFrag.appendChild(htmlNode.childNodes[i]); - } - return parallelNode; - } - - function PreviewToEditorMapping(livePreview) { + function initParent(livePreview) { var codeMirror = livePreview.codeMirror; + var docFrag = null; var marks = MarkTracker(codeMirror); $(".CodeMirror-lines", codeMirror.getWrapperElement()) .on("mouseup", marks.clear); + livePreview.on("channel:created", function() { + livePreview.channel.bind("ptem:highlight", function(trans, params) { + marks.clear(); + if (!docFrag) return; + var element = docFrag.querySelector(params); + if (!element) return; + var interval = nodeToCode(element); + if (!interval) return; + var start = codeMirror.posFromIndex(interval.start); + var end = codeMirror.posFromIndex(interval.end); + var contentStart = codeMirror.posFromIndex(interval.contentStart); + var startCoords = codeMirror.charCoords(start, "local"); + codeMirror.scrollTo(startCoords.x, startCoords.y); + marks.mark(interval.start, interval.end, + "preview-to-editor-highlight"); + codeMirror.focus(); + }); + }); + codeMirror.on("reparse", function(event) { + docFrag = event.document; + marks.clear(); + }); + } + + function initChild(livePreview) { livePreview.on("refresh", function(event) { var docFrag = event.documentFragment; - marks.clear(); $(event.window).on("mousedown", function(event) { - marks.clear(); var tagName = event.target.tagName.toLowerCase(); var interval = null; - if (tagName !== "html" && tagName !== "body") - interval = nodeToCode(event.target, docFrag); - if (interval) { - var start = codeMirror.posFromIndex(interval.start); - var end = codeMirror.posFromIndex(interval.end); - var contentStart = codeMirror.posFromIndex(interval.contentStart); - var startCoords = codeMirror.charCoords(start, "local"); - codeMirror.scrollTo(startCoords.x, startCoords.y); - marks.mark(interval.start, interval.end, - "preview-to-editor-highlight"); - codeMirror.focus(); - event.preventDefault(); - event.stopPropagation(); + if (tagName !== "html" && tagName !== "body") { + var htmlElement = event.target.ownerDocument.documentElement; + livePreview.channel.notify({ + method: "ptem:highlight", + params: "html " + pathTo(htmlElement, event.target) + }); } }); }); } + function PreviewToEditorMapping(livePreview) { + if (livePreview.inEditor) + initParent(livePreview); + else + initChild(livePreview); + } + PreviewToEditorMapping._pathTo = pathTo; PreviewToEditorMapping._nodeToCode = nodeToCode; diff --git a/templates/live-preview-sandbox.html b/templates/live-preview-sandbox.html index 834c99b..95f4f98 100644 --- a/templates/live-preview-sandbox.html +++ b/templates/live-preview-sandbox.html @@ -30,6 +30,7 @@ LivePreview = require("fc/ui/live-preview"), Channel = require("jschannel"), FakeCodemirror = require("fake-codemirror"), + PreviewToEditorMapping = require("fc/ui/preview-to-editor-mapping"), previewArea = $(document.body), preview = LivePreview({ codeMirror: FakeCodemirror(), @@ -43,6 +44,8 @@ onReady: function() {} }); + preview.channel = channel; + preview.on("change:title", function(title) { channel.notify({method: "change:title", params: title}); }); @@ -54,6 +57,8 @@ sourceCode: params.sourceCode }); }); + + PreviewToEditorMapping(preview); }); require(["main"], function() {});