diff --git a/nbpresent/tests/js/_utils.js b/nbpresent/tests/js/_utils.js index e86b418..6939fdd 100644 --- a/nbpresent/tests/js/_utils.js +++ b/nbpresent/tests/js/_utils.js @@ -65,6 +65,29 @@ root.canSeeAndClick = function(message, visible, click){ return that; } +root.cantSee = function(message, invisible){ + var things = message, + that = this; + + if(arguments.length !== 1){ + things = [[message, invisible]]; + } + + things.map(function(thing){ + var message = thing[0], + invisible = thing[1]; + + that = that + .waitWhileVisible(invisible) + .then(function(){ + this.test.assertNotExists(invisible, "I CAN'T see " + message); + this.screenshot("no " + message); + }); + }); + + return that; +}; + root.hasMeta = function(path, tests){ var meta; diff --git a/nbpresent/tests/js/test_notebook_basic.js b/nbpresent/tests/js/test_notebook_basic.js index a5f6298..7fcce48 100644 --- a/nbpresent/tests/js/test_notebook_basic.js +++ b/nbpresent/tests/js/test_notebook_basic.js @@ -25,5 +25,5 @@ function basic_test(){ t.assertResourceExists(function(resource) { return resource.url.match(pattern); }); - }; + } } diff --git a/nbpresent/tests/js/test_notebook_create.js b/nbpresent/tests/js/test_notebook_create.js index 63c364d..bcec5ae 100644 --- a/nbpresent/tests/js/test_notebook_create.js +++ b/nbpresent/tests/js/test_notebook_create.js @@ -70,7 +70,15 @@ function create_test(){ .waitWhileVisible(".nbp-sorter") .waitWhileVisible(".nbp-regiontree") .canSeeAndClick([ - ["the presenter button", "#nbp-present-btn"], - ["the presenter", ".nbp-presenter"] + ["the app bar button", "#nbp-app-btn"], + ["the history button", ".nbp-app-bar .fa-history"], + ["the initial load", ".nbp-snapshot .fa-gift"], + ["the presenter button", "#nbp-present-btn"] + ]) + .cantSee([ + ["any cell content", ".nbp-present"] + ]) + .canSeeAndClick([ + ["fin", "body"] ]); } diff --git a/nbpresent/tests/js/test_notebook_history.js b/nbpresent/tests/js/test_notebook_history.js new file mode 100644 index 0000000..c428568 --- /dev/null +++ b/nbpresent/tests/js/test_notebook_history.js @@ -0,0 +1,19 @@ +/* global casper */ +casper.notebook_test(function(){ + casper.screenshot.init("history"); + casper.viewport(1440, 900) + .then(basic_test); +}); + +function basic_test(){ + this.baseline_notebook(); + + this.canSeeAndClick([ + ["the sorter button", "#nbp-app-btn"], + ["the history button", ".nbp-app-bar .fa-history"] + ]) + .wait(500) + .canSeeAndClick([ + ["the initial load", ".nbp-snapshot .fa-gift"] + ]); +} diff --git a/src/es6/editor.es6 b/src/es6/editor.es6 index a075a0c..721716a 100644 --- a/src/es6/editor.es6 +++ b/src/es6/editor.es6 @@ -1,12 +1,14 @@ import {d3} from "nbpresent-deps"; +import {bbox} from "./d3.bbox"; + +import {ICON} from "./icons"; import {PART} from "./parts"; import {RegionTree} from "./regiontree"; import {NotebookCellManager} from "./cells/notebook"; import {NotebookActions} from "./actions/notebook"; -import {bbox} from "./d3.bbox"; const DIRS = ["left", "right", "up", "down"], DIR_ATTR = { @@ -29,11 +31,7 @@ export class Editor { * @param {baobab.Cursor} slide - the slide to edit * @param {object} selectedRegion - the slide id and region id * @listens {/slides/{slide}} */ - constructor(slide, selectedRegion) { - /** whether this Editor has been killed. - * @type {bool} */ - this.destroyed = false; - + constructor(slide, selectedRegion, mode) { /** cursor pointed at a specific slide. * @type {baobab.Cursor} */ this.slide = slide; @@ -43,6 +41,14 @@ export class Editor { * @param {baobab.Cursor} the region/slide to edit */ this.selectedRegion = selectedRegion; + /** Handlers, like snapshot + * @type {object} */ + this.mode = mode; + + /** whether this Editor has been killed. + * @type {bool} */ + this.destroyed = false; + /** a sub-cursor for just region changes * @type {baobab.Cursor} */ this.regions = this.slide.select("regions"); @@ -66,7 +72,8 @@ export class Editor { // TODO: move this? /** sub-ui for editing regions * @type {RegionTree} */ - this.sidebar = new RegionTree(this.slide, this.selectedRegion); + this.sidebar = new RegionTree(this.slide, this.selectedRegion, + this.mode); // ready to behave this.initBehavior() @@ -172,6 +179,8 @@ export class Editor { memo[key] = value.invert($el.attr(key)); return memo; }, {})); + + this.mode.snapshot("Move/Resize Region", ICON.manual); } /** @@ -290,7 +299,8 @@ export class Editor { this.regions.set( [region, "attrs", attr], this.regions.get([region, "attrs", attr]) + (delta * amount) - ) + ); + this.mode.snapshot("Nudge Region", ICON.manual); } return this; } diff --git a/src/es6/history/historian.es6 b/src/es6/history/historian.es6 new file mode 100644 index 0000000..3fdaf8f --- /dev/null +++ b/src/es6/history/historian.es6 @@ -0,0 +1,93 @@ +import {d3, _} from "nbpresent-deps"; + + +export class Historian { + constructor(tree, {mode}){ + this.tree = tree; + this.mode = mode; + + this.head = this.tree.select(["history", "head"]); + this.snapshots = this.tree.select(["history", "snapshots"]); + + this.watcher = this.tree.watch({ + head: this.head, + snapshots: this.snapshots + }); + this.watcher.on("update", () => this.update()); + + this.initUI(); + _.delay(() => { + this.$body.classed({"nbp-curating": 1}); + this.update(); + }, 10); + } + + initUI(){ + this.$body = d3.select("body"); + this.$ui = this.$body.append("div") + .classed({"nbp-historian": 1}); + + this.$shots = this.$ui.append("div") + .classed({"nbp-snapshots": 1}); + + this.$h2 = this.$ui.append("h2") + .text("history ") + + this.$h2.append("hr"); + } + + update(){ + let history = this.watcher.get(), + snapshots = _.sortBy(d3.entries(history.snapshots), "key").reverse(), + prevHead = history.head, + headLogIds = [null, history.head], + prevShot; + + while(prevHead){ + prevShot = history.snapshots[prevHead]; + prevHead = prevShot && prevShot.parent; + prevHead && headLogIds.push(prevHead); + } + + let $shot = this.$shots.selectAll(".nbp-snapshot") + .data(snapshots); + + $shot.enter().append("div") + .classed({"nbp-snapshot": 1}) + .append("button") + .classed({"btn btn-default": 1}) + .on("click", (d) => this.rollback(d)) + .call((btn) => { + btn.append("i").classed({"fa fa-fw": 1}); + btn.append("span"); + }); + + $shot.exit().remove(); + + $shot.classed({ + "nbp-history-head": (d) => d.key === history.head, + "nbp-history-not-head-parent": (d) => headLogIds.indexOf(d.key) === -1 + }) + .select("button") + .call((btn) => { + btn.select("span") + .text((d) => d.value.message) + btn.select("i") + .attr("class", (d) => `fa fa-fw fa-${d.value.icon}`) + }) + + return this; + } + + rollback(snapshotEntry){ + this.head.set(snapshotEntry.key); + this.tree.set(["slides"], snapshotEntry.value.state.slides); + this.tree.set(["themes"], snapshotEntry.value.state.themes); + } + + destroy(){ + this.$body.classed({"nbp-curating": 0}); + this.watcher.release(); + _.delay(() => this.$ui.remove(), 500); + } +} diff --git a/src/es6/icons.es6 b/src/es6/icons.es6 index d5b1df3..b578b77 100644 --- a/src/es6/icons.es6 +++ b/src/es6/icons.es6 @@ -1,20 +1,22 @@ export const ICON = { - showRules: "adjust", addRegion: "plus-square", addSlide: "plus-square-o", addTheme: "plus-circle", - defaultThemeActive: "star", defaultTheme: "star-o", + defaultThemeActive: "star", editor: "edit", editRegion: "edit", grid: "th", help: "question-circle", + history: "history", intro: "home", + keyboard: "keyboard-o", link: "link", manual: "arrows", nbpresent: "gift", presenter: "youtube-play", preview: "eye-slash", + showRules: "adjust", slides: "film", themer: "paint-brush", trash: "trash", diff --git a/src/es6/mode/notebook.es6 b/src/es6/mode/notebook.es6 index 9d144e7..f495eb1 100644 --- a/src/es6/mode/notebook.es6 +++ b/src/es6/mode/notebook.es6 @@ -10,6 +10,7 @@ import {NotebookPresenter} from "../presenter/notebook"; import {Sorter} from "../sorter"; import {ThemeManager} from "../theme/manager"; import {Helper} from "../help/helper"; +import {Historian} from "../history/historian"; import {BaseMode} from "./base"; @@ -19,17 +20,25 @@ import {NotebookActions} from "../actions/notebook"; export const THEMER = "themer", SORTER = "sorter", HELPER = "helper", + HISTORIAN = "historian", MODES = [ - THEMER, + HELPER, + HISTORIAN, SORTER, - HELPER + THEMER ]; -export class NotebookMode extends BaseMode { +export class NotebookMode extends BaseMode { init() { super.init(); + this.history = this.tree.select(["history"]); + this.history.set({ + head: null, + snapshots: {} + }); + this.enabled = this.tree.select(["app", "enabled"]); this.enabled.on("update", () => this.enabledChanged()); @@ -38,12 +47,16 @@ export class NotebookMode extends BaseMode { let debouncedSave = _.debounce(() => this.metadata(true), 1e3); - [this.slides, this.themes].map(({on}) => on("update", debouncedSave)); + this.tree.watch({ + slides: this.slides, + themes: this.themes + }).on("update", () => debouncedSave()); this.slides.on("update", () => this.update()); this.initActions(); this.initEvents(); + this.initSnapshot(); this.$body = d3.select("body"); @@ -77,6 +90,11 @@ export class NotebookMode extends BaseMode { label: "Themes", click: () => this.mode.set(this.mode.get() === THEMER ? null : THEMER) }], + [{ + icon: `${ICON.history} fa-2x`, + label: "History", + click: () => this.mode.set(this.mode.get() === HISTORIAN ? null : HISTORIAN) + }], [{ icon: `${ICON.help} fa-2x`, label: "Help", @@ -149,10 +167,9 @@ export class NotebookMode extends BaseMode { metadata(update){ let md = Jupyter.notebook.metadata; if(update){ - md.nbpresent = { - slides: this.slides.serialize(), - themes: this.themes.serialize() - }; + let slides = this.slides.serialize(), + themes = this.themes.serialize(); + md.nbpresent = {slides, themes}; }else{ return md.nbpresent || { slides: {}, @@ -161,6 +178,28 @@ export class NotebookMode extends BaseMode { } } + initSnapshot(){ + this.snapshot("Load from Server", ICON.nbpresent); + } + + snapshot(message, icon){ + let history = this.tree.get(["history"]), + snapshotId = (new Date()).toISOString(), + state = { + slides: this.slides.serialize(), + themes: this.themes.serialize() + }; + + this.history.set(["snapshots", snapshotId], { + state, + parent: history.head, + message: message, + icon: icon + }); + + this.history.set(["head"], snapshotId); + } + enabledChanged(){ let enabled = this.enabled.get(); @@ -182,10 +221,12 @@ export class NotebookMode extends BaseMode { modeClass(mode){ + // TODO: some plugin thing or another return { themer: ThemeManager, sorter: Sorter, - helper: Helper + helper: Helper, + historian: Historian }[mode]; } @@ -220,7 +261,6 @@ export class NotebookMode extends BaseMode { return this; } - ensurePresenter(){ if(!(this.presenter)){ this.presenter = new NotebookPresenter(this.tree); diff --git a/src/es6/regiontree.es6 b/src/es6/regiontree.es6 index 8a82b07..0cc5a69 100644 --- a/src/es6/regiontree.es6 +++ b/src/es6/regiontree.es6 @@ -8,9 +8,10 @@ import {MiniSlide} from "./mini"; class RegionTree { - constructor(slide, region){ + constructor(slide, region, mode){ this.slide = slide; this.selectedRegion = region; + this.mode = mode; this.mini = (new MiniSlide(this.selectedRegion)) .regions((d) => { @@ -50,6 +51,7 @@ class RegionTree { * @return {RegionTree} */ layout(layout){ this.slide.set("layout", layout); + this.mode.snapshot("Slide Layout", ICON.manual); return this; } @@ -85,12 +87,6 @@ class RegionTree { .call(toolbar.update); } - toggleStyle(style){ - let {region} = this.selectedRegion.get() || {}, - path = ["regions", region, "style", style]; - this.slide.set(path, !(this.slide.get(path))); - } - addRegion(){ let id = uuid.v4(); this.slide.set(["regions", id], { @@ -102,6 +98,7 @@ class RegionTree { height: 0.8 } }); + this.mode.snapshot("New Region", ICON.addRegion); } update(){ @@ -191,6 +188,7 @@ class RegionTree { let el = d3.select(this), val = parseFloat(el.property("value")); that.slide.set(["regions", d.region.key, d.attr.key], val); + that.handlers.snapshot(`Set ${d.attr.key}`, ICON.keyboard); }); }) .on("mousedown", function(d){ @@ -249,11 +247,13 @@ class RegionTree { that.slide.set(path, that.slide.get(path) + dx) }) - .on("mouseup", function(){ + .on("mouseup", (d) => { el.on("mousemove", null); + this.mode.snapshot(`Set ${d.attr.key}`, ICON.keyboard); }) - .on("mouseexit", function(){ + .on("mouseexit", (d) => { el.on("mousemove", null); + this.mode.snapshot(`Set ${d.attr.key}`, ICON.keyboard); }); } diff --git a/src/es6/sorter.es6 b/src/es6/sorter.es6 index 7af6d6c..f4e36ab 100644 --- a/src/es6/sorter.es6 +++ b/src/es6/sorter.es6 @@ -32,8 +32,10 @@ const MODE_NAMES = [ * defining the relationship between notebook cells {@link PART}s and slide * regions. Not available in {@link StandaloneMode} */ class Sorter { - constructor(tree) { + constructor(tree, {mode}) { this.tree = tree; + this.mode = mode; + this.cellManager = new NotebookCellManager(); this.cellManager.clearThumbnails(); @@ -215,6 +217,7 @@ class Sorter { if(m[0] > 0 && m[0] < bb.width && m[1] > 0 && m[1] < bb.height){ that.unlinkSlide(d.key); that.selectedSlide.set(that.appendSlide(other.key, d.key)); + that.mode.snapshot(`Reordered Slides`, ICON.film); replaced = true; } }); @@ -276,7 +279,10 @@ class Sorter { .text((d) => d.title()); tome.append("button") .classed({btn: 1}) - .on("click", (d)=> d.execute()) + .on("click", (d) => { + d.execute(); + this.mode.snapshot(`${d.title()} Import`, d.icon()); + }) .call((btn)=>{ btn.append("i") .attr("class", (d) => `fa fa-2x fa-${d.icon()}`); @@ -489,6 +495,7 @@ class Sorter { .on("click", () => this.$themePicker.remove()) .on("mouseover", ({key}) => { this.slides.set([this.selectedSlide.get(), "theme"], key); + this.mode.snapshot(`Set Slide Theme`, ICON.themer); }); theme.exit().remove(); @@ -547,8 +554,10 @@ class Sorter { cell: cellId, part }); + this.mode.snapshot(`Link Cell Part`, ICON.link); }else{ this.slides.unset([slide, "regions", region, "content"]); + this.mode.snapshot(`Unlink Cell Part`, ICON.unlink); } return this; } @@ -561,6 +570,7 @@ class Sorter { return; } this.slides.unset([slide, "regions", region]); + this.mode.snapshot(`Delete region`, ICON.trash); this.selectedRegion.unset(); return this; } @@ -588,6 +598,7 @@ class Sorter { selected ? selected : last.length ? last[0].key : null, slide.key ); + this.mode.snapshot(`Add Slide`, ICON.addSlide); this.selectedSlide.set(appended); } @@ -607,7 +618,11 @@ class Sorter { if(id){ this.focusMode(["editor"]); // TODO: do this with an id and big tree ref? - this.editor = new Editor(this.slides.select([id]), this.selectedRegion); + this.editor = new Editor( + this.slides.select([id]), + this.selectedRegion, + {snapshot: (message, icon) => this.mode.snapshot(message, icon)} + ); } this.draw(); } @@ -630,6 +645,7 @@ class Sorter { } this.unlinkSlide(id); this.slides.unset(id); + this.mode.snapshot(`Remove Slide`, ICON.trash); } nextSlide(id){ diff --git a/src/less/history.less b/src/less/history.less new file mode 100644 index 0000000..ea146f7 --- /dev/null +++ b/src/less/history.less @@ -0,0 +1,59 @@ +body.nbp-curating { + .nbp-historian{ + margin-right: 0; + transition: margin-right @tx-dur @tx-fn; + } +} + +.nbp-historian { + .nbp-ui(); + + overflow-y: auto; + + z-index: @z-help; + + position: fixed; + + right: @app-bar-width; + width: @sorter-slide-width * 1.5; + top: 0; + bottom: 0; + + margin-right: -@sorter-slide-width * 1.5; + transition: margin-right @tx-dur @tx-fn; + padding-top: 32px; + + background-color: @ui-bg; + + hr { + margin: 0 20px; + padding: 0; + border-top: solid 1px fade(@jpy-brand, 50%); + } + + h1, h2, h3 { + color: @jpy-brand; + text-align: center; + } + + h2{ + font-size: 18px; + position: absolute; + left: 0; + right: 0; + background-color: @ui-bg; + top: 0; + } + + .nbp-snapshot { + border-left: solid 3px transparent; + + &.nbp-history-head { + border-left: solid 3px @jpy-brand; + } + + &.nbp-history-not-head-parent{ + opacity: 0.25; + } + } +} diff --git a/src/less/index.less b/src/less/index.less index 297df95..2a488e3 100644 --- a/src/less/index.less +++ b/src/less/index.less @@ -1,6 +1,7 @@ @import "mixins"; @import "app"; @import "help"; +@import "history"; @import "toolbar"; @import "editor"; @import "sorter";