diff --git a/grunt/nw.js b/grunt/nw.js index d11bd3757..eefcdf520 100644 --- a/grunt/nw.js +++ b/grunt/nw.js @@ -1,12 +1,12 @@ -var wrench = require('wrench'); +var chmodr = require('chmodr'); module.exports = function(grunt) { - // nwjs generates NW.js apps. + /* nwjs generates NW.js apps. */ var options = { buildDir: 'build/nwjs/', cacheDir: 'nwbuilder-cache/', - version: '0.17.4', + version: '0.18.7', macIcns: 'src/common/img/logo.icns', winIco: 'src/common/img/logo.ico' } @@ -40,17 +40,29 @@ module.exports = function(grunt) { } }); - // nwjs:osxcleanup corrects a permissions problem on OS X versions of the NW.js app. + /* + nwjs:osxcleanup corrects a permissions problem on OS X versions of the + NW.js app. + */ grunt.registerTask('nwjs:osxcleanup', function() { - wrench.chmodSyncRecursive('build/nwjs/Twine/osx64/Twine.app', 0755); + chmodr.sync('build/nwjs/Twine/osx64/Twine.app', 0755); }); - // nw builds NW.js apps from the contents of build/standalone. + /* + nw builds NW.js apps from the contents of build/standalone. + */ ['osx','win','linux'].forEach(plat => { grunt.registerTask('nw:' + plat, ['build:release', 'nwjs:' + plat, plat == 'osx' ? 'nwjs:osxcleanup' : '']); }); - grunt.registerTask('nw', ['build:release', 'nwjs:osx', 'nwjs:osxcleanup', 'nwjs:win64', 'nwjs:win32', 'nwjs:linux']); + grunt.registerTask('nw', [ + 'build:release', + 'nwjs:osx', + 'nwjs:osxcleanup', + 'nwjs:win64', + 'nwjs:win32', + 'nwjs:linux' + ]); }; diff --git a/grunt/test.js b/grunt/test.js index c7ed5f374..236cd90ab 100644 --- a/grunt/test.js +++ b/grunt/test.js @@ -1,5 +1,5 @@ module.exports = function(grunt) { - // eslint checks JavaScript files for potential problems. + /* eslint checks JavaScript files for potential problems. */ grunt.config.merge({ eslint: { @@ -10,7 +10,7 @@ module.exports = function(grunt) { } }); - // jscs checks JavaScript files for style issues. + /* jscs checks JavaScript files for style issues. */ grunt.config.merge({ jscs: { @@ -24,42 +24,30 @@ module.exports = function(grunt) { } }); - // lint lints everything. + /* lint lints everything. */ grunt.registerTask('lint', ['eslint', 'jscs']); - // mocha runs browser-based tests. - // --grep only runs tests matching a regular expression. - // --bail stops testing on any failure. + /* mochify runs unit tests. */ grunt.config.merge({ - mochaTest: { - selenium: { - src: ['./tests/selenium/*.js'], - options: { - bail: grunt.option('bail'), - grep: grunt.option('grep'), - slow: 5000 - } - } - }, mochify: { spec: { src: ['./src/**/*.spec.js'], options: { phantomjs: grunt.option('phantomjs') || 'phantomjs', reporter: 'dot', - // This contrivance is required in order to force grunt-mochify to call - // mochify with multiple --transform values (which it normally cannot). + /* + This contrivance is required in order to force grunt-mochify to call + mochify with multiple --transform values (which it normally cannot). + */ transform: 'stringify --transform [ babelify --presets babel-preset-es2015 ]'.split(' ') } } } }); - // test tests everything. + /* test tests everything. */ - grunt.registerTask('test', ['test:unit', 'test:selenium']); - grunt.registerTask('test:selenium', ['mochaTest:selenium']); - grunt.registerTask('test:unit', ['mochify:unit']); + grunt.registerTask('test', ['mochify:spec']); }; diff --git a/package.json b/package.json index 0ce59b471..c9fe74dc7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Twine", - "version": "2.1.0b2", + "version": "2.1.0b3", "author": "Chris Klimas ", "description": "a GUI for creating nonlinear stories", "license": "GPL-3.0", @@ -21,8 +21,8 @@ "blob-polyfill": "^1.0.20150320", "browser-saveas": "^1.0.0", "codemirror": "^5.5.0", - "dom-event-special": "^0.1.7", "core-js": "^2.4.0", + "dom-event-special": "^0.1.7", "fastclick": "^1.0.6", "font-awesome": "^4.3.0", "jed": "^1.1.0", @@ -32,6 +32,7 @@ "osenv": "^0.1.3", "scroll-to-element": "^2.0.0", "segseg": "^0.2.2", + "semver-utils": "^1.1.1", "svg.js": "^1.0.1", "tether-drop": "^1.4.2", "tiny-uuid": "^1.0.0", @@ -43,33 +44,32 @@ "devDependencies": { "babel-preset-es2015": "^6.6.0", "babelify": "^7.2.0", - "browserify": "^11.0.0", + "browserify": "^11.2.0", "browserify-shim": "^3.8.10", "chai": "^3.5.0", + "chmodr": "^1.0.2", "ejsify": "^1.0.0", "envify": "^3.4.0", - "grunt": "^0.4.5", - "grunt-browserify": "^4.0.1", - "grunt-contrib-clean": "^0.6.0", - "grunt-contrib-copy": "^0.8.1", - "grunt-contrib-less": "^1.2.0", - "grunt-contrib-watch": "^0.6.1", + "grunt": "^1.0.0", + "grunt-browserify": "^5.0.0", + "grunt-contrib-clean": "^1.0.0", + "grunt-contrib-copy": "^1.0.0", + "grunt-contrib-less": "^1.4.0", + "grunt-contrib-watch": "^1.0.0", "grunt-eslint": "^19.0.0", - "grunt-jscs": "^2.7.0", + "grunt-jscs": "^3.0.1", "grunt-jsdoc": "^1.0.0", - "grunt-mocha-test": "^0.12.7", "grunt-mochify": "^0.3.0", - "grunt-nw-builder": "^2.0.0", + "grunt-nw-builder": "^3.1.0", "grunt-po2json": "^0.3.0", - "grunt-template": "^0.2.3", - "jit-grunt": "^0.9.1", + "grunt-template": "^1.0.0", + "jit-grunt": "^0.10.0", "less-plugin-autoprefix": "^1.5.1", "less-plugin-clean-css": "^1.5.1", "selenium-webdriver": "^2.52.0", "sinon": "^1.17.5", "stringify": "^5.1.0", "uglifyify": "^3.0.1", - "watchify": "^3.3.0", - "wrench": "^1.5.8" + "watchify": "^3.7.0" } } diff --git a/src/common/app/index.js b/src/common/app/index.js index 68ceffc09..bc82791a4 100644 --- a/src/common/app/index.js +++ b/src/common/app/index.js @@ -3,7 +3,7 @@ 'use strict'; const Vue = require('vue'); const ui = require('../../ui'); -const { repairFormats } = require('../../data/actions'); +const { repairFormats, repairStories } = require('../../data/actions'); const store = require('../../data/store'); module.exports = Vue.extend({ @@ -12,6 +12,7 @@ module.exports = Vue.extend({ ready() { ui.init(); this.repairFormats(); + this.repairStories(); document.body.classList.add(`theme-${this.themePref}`); }, @@ -23,7 +24,7 @@ module.exports = Vue.extend({ }, vuex: { - actions: { repairFormats }, + actions: { repairFormats, repairStories }, getters: { themePref: state => state.pref.appTheme } diff --git a/src/common/router.js b/src/common/router.js index 3d243bd08..9c35bbf50 100644 --- a/src/common/router.js +++ b/src/common/router.js @@ -1,4 +1,4 @@ -// The router managing the app's views. +/* The router managing the app's views. */ let Vue = require('vue'); const VueRouter = require('vue-router'); @@ -16,7 +16,7 @@ Vue.use(VueRouter); let TwineRouter = new VueRouter(); TwineRouter.map({ - // We connect routes with no params directly to a component. + /* We connect routes with no params directly to a component. */ '/locale': { component: LocaleView @@ -26,8 +26,10 @@ TwineRouter.map({ component: WelcomeView }, - // For routes that take data objects, we create shim components which provide - // appropriate props to the components that do the actual work. + /* + For routes that take data objects, we create shim components which provide + appropriate props to the components that do the actual work. + */ '/stories': { component: { @@ -57,8 +59,10 @@ TwineRouter.map({ }, }, - // These routes require special handling, because we tear down our UI when - // they activate. + /* + These routes require special handling, because we tear down our UI when + they activate. + */ '/stories/:id/play': { component: { @@ -68,12 +72,11 @@ TwineRouter.map({ story => story.id === this.$route.params.id ); - const formatName = story.storyFormat || state.pref.defaultFormat; - const format = state.storyFormat.formats.find( - format => format.name === formatName - ); - - loadFormat(this.$store, formatName).then(() => { + loadFormat( + this.$store, + story.storyFormat, + story.storyFormatVersion + ).then(format => { replaceUI(publishStoryWithFormat( state.appInfo, story, @@ -91,11 +94,12 @@ TwineRouter.map({ const story = state.story.stories.find( story => story.id === this.$route.params.id ); - const format = state.storyFormat.formats.find( - format => format.name === state.pref.proofingFormat - ); - loadFormat(this.$store, format.name).then(() => { + loadFormat( + this.$store, + story.storyFormat, + story.storyFormatVersion + ).then(format => { replaceUI(publishStoryWithFormat( state.appInfo, story, @@ -113,12 +117,12 @@ TwineRouter.map({ const story = state.story.stories.find( story => story.id === this.$route.params.id ); - const formatName = story.storyFormat || state.pref.defaultFormat; - const format = state.storyFormat.formats.find( - format => format.name === formatName - ); - loadFormat(this.$store, formatName).then(() => { + loadFormat( + this.$store, + story.storyFormat, + story.storyFormatVersion + ).then(format => { replaceUI(publishStoryWithFormat( state.appInfo, story, @@ -137,12 +141,12 @@ TwineRouter.map({ const story = state.story.stories.find( story => story.id === this.$route.params.storyId ); - const formatName = story.storyFormat || state.pref.defaultFormat; - const format = state.storyFormat.formats.find( - format => format.name === formatName - ); - loadFormat(this.$store, formatName).then(() => { + loadFormat( + this.$store, + story.storyFormat, + story.storyFormatName + ).then(format => { replaceUI(publishStoryWithFormat( state.appInfo, story, @@ -156,16 +160,18 @@ TwineRouter.map({ } }); -// By default, show the story list. +/* By default, show the story list. */ TwineRouter.redirect({ '*': '/stories' }); -TwineRouter.beforeEach((transition) => { - // If we are moving from an edit view to a list view, give the list view - // the story that we were previously editing, so that it can display a - // zooming transition back to the story. +TwineRouter.beforeEach(transition => { + /* + If we are moving from an edit view to a list view, give the list view the + story that we were previously editing, so that it can display a zooming + transition back to the story. + */ if (transition.from.path && transition.to.path === '/stories') { const editingId = @@ -176,10 +182,11 @@ TwineRouter.beforeEach((transition) => { } } - // If the user has never used the app before, point them to the welcome - // view first. This has to come below any other logic, as calling - // transition.next() or redirect() will stop any other logic in the - // function. + /* + If the user has never used the app before, point them to the welcome view + first. This has to come below any other logic, as calling transition.next() + or redirect() will stop any other logic in the function. + */ const welcomeSeen = store.state.pref.welcomeSeen; diff --git a/src/data/actions.js b/src/data/actions.js index 737b9465c..829e3b653 100644 --- a/src/data/actions.js +++ b/src/data/actions.js @@ -1,6 +1,9 @@ -// Vuex actions that components can use. +/* +Vuex actions that components can use. +*/ const $ = require('jquery'); +const semverUtils = require('semver-utils'); const linkParser = require('./link-parser'); const locale = require('../locale'); const rect = require('../common/rect'); @@ -42,8 +45,10 @@ const actions = module.exports = { dispatch('DELETE_PASSAGE_IN_STORY', storyId, passageId); }, - // Moves a passage so it doesn't overlap any other in its story, and also - // snaps to a grid. + /* + Moves a passage so it doesn't overlap any other in its story, and also + snaps to a grid. + */ positionPassage(store, storyId, passageId, gridSize, filter) { const story = store.state.story.stories.find( @@ -64,7 +69,7 @@ const actions = module.exports = { ); } - // Displace by other passages. + /* Displace by other passages. */ let passageRect = { top: passage.top, @@ -90,7 +95,7 @@ const actions = module.exports = { } }); - // Snap to the grid. + /* Snap to the grid. */ if (story.snapToGrid && gridSize !== 0) { passageRect.left = Math.round(passageRect.left / gridSize) * @@ -99,7 +104,7 @@ const actions = module.exports = { gridSize; } - // Save the change. + /* Save the change. */ actions.updatePassageInStory( store, @@ -112,8 +117,9 @@ const actions = module.exports = { ); }, - // Adds new passages to a story based on new links added in a passage's - // text. + /* + Adds new passages to a story based on new links added in a passage's text. + */ createNewlyLinkedPassages(store, storyId, passageId, oldText) { const story = store.state.story.stories.find( @@ -123,7 +129,7 @@ const actions = module.exports = { passage => passage.id === passageId ); - // Determine how many passages we'll need to create. + /* Determine how many passages we'll need to create. */ const oldLinks = linkParser(oldText, true); const newLinks = linkParser(passage.text, true).filter( @@ -131,12 +137,14 @@ const actions = module.exports = { !(story.passages.some(passage => passage.name === link)) ); - // We center the new passages underneath this one. + /* We center the new passages underneath this one. */ const newTop = passage.top + 100 * 1.5; - // We account for the total width of the new passages as both the - // width of the passages themselves plus the spacing in between. + /* + We account for the total width of the new passages as both the width of + the passages themselves plus the spacing in between. + */ const totalWidth = newLinks.length * 100 + ((newLinks.length - 1) * (100 / 2)); @@ -263,11 +271,39 @@ const actions = module.exports = { }); }, - loadFormat(store, name) { - const format = store.state.storyFormat.formats.find( - format => format.name === name + loadFormat(store, name, version) { + /* + We pick the highest version that matches the major version of the + string (e.g. if we ask for version 2.0.8, we may get 2.6.1). + */ + + const majorVersion = semverUtils.parse(version).major; + const formats = store.state.storyFormat.formats.filter( + format => format.name === name && + semverUtils.parse(format.version).major === majorVersion ); + if (formats.length === 0) { + throw new Error('No format is available named ' + name); + } + + const format = formats.reduce((prev, current) => { + const pVer = semverUtils.parse(prev.version); + const cVer = semverUtils.parse(current.version); + + if (cVer.major === pVer.major && (parseInt(cVer.minor) > + parseInt(pVer.minor) || parseInt(cVer.patch) > + parseInt(pVer.minor))) { + return current; + } + + return previous; + }); + + if (!format) { + throw new Error('No format is available for version ' + version); + } + return new Promise((resolve, reject) => { if (format.loaded) { resolve(format); @@ -290,65 +326,165 @@ const actions = module.exports = { }); }, - // Create built-in formats and repair paths to use kebab case, as in - // previous versions we used camel case. + /* + Create built-in formats, repair paths to use kebab case (in previous + versions we used camel case), and set version numbers. + */ repairFormats(store) { - // Create built-in story formats if they don't already exist. + /* + Delete unversioned formats. + */ + + store.state.storyFormat.formats.forEach(format => { + if (typeof format.version !== 'string' || format.version === '') { + actions.deleteFormat(store, format.id); + } + }); + + /* + Create built-in story formats if they don't already exist. + */ const builtinFormats = [ { name: 'Harlowe', - url: 'story-formats/Harlowe/format.js', + url: 'story-formats/harlowe-1.2.3/format.js', + version: '1.2.3', + userAdded: false + }, + { + name: 'Harlowe', + url: 'story-formats/harlowe-2.0.0/format.js', + version: '2.0.0', userAdded: false }, { name: 'Paperthin', - url: 'story-formats/Paperthin/format.js', + url: 'story-formats/paperthin-1.0.0/format.js', + version: '1.0.0', userAdded: false }, { name: 'Snowman', - url: 'story-formats/Snowman/format.js', + url: 'story-formats/snowman-1.3.0/format.js', + version: '1.3.0', userAdded: false }, { name: 'SugarCube', - url: 'story-formats/SugarCube/format.js', + url: 'story-formats/sugarcube-1.0.35/format.js', + version: '1.0.35', + userAdded: false + }, + { + name: 'SugarCube', + url: 'story-formats/sugarcube-2.11.0/format.js', + version: '2.11.0', userAdded: false } ]; builtinFormats.forEach(builtin => { if (!store.state.storyFormat.formats.find( - format => format.name === builtin.name + format => format.name === builtin.name && + format.version === builtin.version )) { actions.createFormat(store, builtin); } }); - // Set default formats if not already set. + /* + Set default formats if not already set, or if an unversioned preference + exists. + */ - if (!store.state.pref.defaultFormat) { - actions.setPref(store, 'defaultFormat', 'Harlowe'); + if (typeof store.state.pref.defaultFormat !== 'object') { + actions.setPref( + store, + 'defaultFormat', + { name: 'Harlowe', version: '1.2.3' } + ); } - if (!store.state.pref.proofingFormat) { - actions.setPref(store, 'proofingFormat', 'Paperthin'); + if (typeof store.state.pref.proofingFormat !== 'object') { + actions.setPref( + store, + 'proofingFormat', + { name: 'Paperthin', version: '1.0.0' } + ); } + }, - store.state.storyFormat.formats.forEach(format => { - if (/^storyFormats\//i.test(format.url)) { - actions.updateFormat( + /* + Repairs stories by ensuring that they always have a story format and + version set. + */ + + repairStories(store) { + store.state.story.stories.forEach(story => { + /* + Reset stories without any story format. + */ + + if (!story.storyFormat) { + actions.updateStory( store, - format.id, - { - url: format.url.replace( - /^storyFormats\//i, 'story-formats/' - ) - } + story.id, + { storyFormat: store.state.pref.defaultFormat.name } ); } + + /* + Coerce old SugarCube formats, which had version numbers in their + name, to the correct built-in ones. + */ + + if (/^SugarCube 1/.test(story.storyFormat)) { + actions.updateStory( + store, + story.id, + { storyFormat: 'SugarCube', storyFormatVersion: '1.0.35' } + ); + } + else if (/^SugarCube 2/.test(story.storyFormat)) { + actions.updateStory( + store, + story.id, + { storyFormat: 'SugarCube', storyFormatVersion: '2.11.0' } + ); + } + else if (!story.storyFormatVersion) { + /* + If a story has no format version, pick the lowest version number + currently available. + */ + + const format = store.state.storyFormat.formats.reduce((prev, current) => { + if (current.name !== story.storyFormat) { + return prev; + } + + const pVer = semverUtils.parse(prev.version); + const cVer = semverUtils.parse(current.version); + + if (parseInt(cVer.major) < parseInt(pVer.major) || + parseInt(cVer.minor) < parseInt(pVer.minor) || + parseInt(cVer.patch) < parseInt(pVer.patch)) { + return current; + } + + return prev; + }); + + if (format) { + actions.updateStory( + store, + story.id, + { storyFormatVersion: format.version } + ); + } + } }); } }; diff --git a/src/data/actions.spec.js b/src/data/actions.spec.js index 3180771d6..ff49a2d18 100644 --- a/src/data/actions.spec.js +++ b/src/data/actions.spec.js @@ -102,22 +102,28 @@ describe('actions data module', () => { let call = formatsStore.dispatch.getCall(i); if (call.args[0] === 'CREATE_FORMAT') { - created[call.args[1].name] = call.args[1]; + created[call.args[1].name + '-' + call.args[1].version] = call.args[1]; } } - expect(created.Harlowe).to.exist; - expect(created.Harlowe.url).to.equal('story-formats/Harlowe/format.js'); - expect(created.Harlowe.userAdded).to.be.false; - expect(created.Paperthin).to.exist; - expect(created.Paperthin.url).to.equal('story-formats/Paperthin/format.js'); - expect(created.Paperthin.userAdded).to.be.false; - expect(created.Snowman).to.exist; - expect(created.Snowman.url).to.equal('story-formats/Snowman/format.js'); - expect(created.Snowman.userAdded).to.be.false; - expect(created.SugarCube).to.exist; - expect(created.SugarCube.url).to.equal('story-formats/SugarCube/format.js'); - expect(created.SugarCube.userAdded).to.be.false; + expect(created['Harlowe-1.2.3']).to.exist; + expect(created['Harlowe-1.2.3'].url).to.equal('story-formats/harlowe-1.2.3/format.js'); + expect(created['Harlowe-1.2.3'].userAdded).to.be.false; + expect(created['Harlowe-2.0.0']).to.exist; + expect(created['Harlowe-2.0.0'].url).to.equal('story-formats/harlowe-2.0.0/format.js'); + expect(created['Harlowe-2.0.0'].userAdded).to.be.false; + expect(created['Paperthin-1.0.0']).to.exist; + expect(created['Paperthin-1.0.0'].url).to.equal('story-formats/paperthin-1.0.0/format.js'); + expect(created['Paperthin-1.0.0'].userAdded).to.be.false; + expect(created['Snowman-1.3.0']).to.exist; + expect(created['Snowman-1.3.0'].url).to.equal('story-formats/snowman-1.3.0/format.js'); + expect(created['Snowman-1.3.0'].userAdded).to.be.false; + expect(created['SugarCube-1.0.35']).to.exist; + expect(created['SugarCube-1.0.35'].url).to.equal('story-formats/sugarcube-1.0.35/format.js'); + expect(created['SugarCube-1.0.35'].userAdded).to.be.false; + expect(created['SugarCube-2.11.0']).to.exist; + expect(created['SugarCube-2.11.0'].url).to.equal('story-formats/sugarcube-2.11.0/format.js'); + expect(created['SugarCube-2.11.0'].userAdded).to.be.false; }); it('sets default formats with repairFormats()', () => { @@ -133,8 +139,29 @@ describe('actions data module', () => { actions.repairFormats(formatsStore); - expect(formatsStore.dispatch.calledWith('UPDATE_PREF', 'defaultFormat', 'Harlowe')); - expect(formatsStore.dispatch.calledWith('UPDATE_PREF', 'proofingFormat', 'Paperthin')); + expect(formatsStore.dispatch.calledWith( + 'UPDATE_PREF', 'defaultFormat', { name: 'Harlowe', version: '1.2.3' } + )).to.be.true; + expect(formatsStore.dispatch.calledWith( + 'UPDATE_PREF', 'proofingFormat', { name: 'Paperthin', version: '1.0.0' } + )).to.be.true; + }); + + it('deletes unversioned formats with repairFormats()', () => { + let formatsStore = { + dispatch: spy(), + state: { + pref: {}, + storyFormat: { + formats: [ + { name: 'Test' } + ] + } + } + }; + + actions.repairFormats(formatsStore); + expect(formatsStore.dispatch.calledWith('DELETE_FORMAT')).to.be.true; }); it('does not duplicate formats with repairFormats()', () => { @@ -144,10 +171,12 @@ describe('actions data module', () => { pref: {}, storyFormat: { formats: [ - { name: 'Harlowe' }, - { name: 'Paperthin' }, - { name: 'Snowman' }, - { name: 'SugarCube' } + { name: 'Harlowe', version: '1.2.3' }, + { name: 'Harlowe', version: '2.0.0' }, + { name: 'Paperthin', version: '1.0.0' }, + { name: 'Snowman', version: '1.3.0' }, + { name: 'SugarCube', version: '1.0.35' }, + { name: 'SugarCube', version: '2.11.0' } ] } } @@ -157,6 +186,108 @@ describe('actions data module', () => { expect(formatsStore.dispatch.calledWith('CREATE_FORMAT')).to.be.false; }); + it('sets default formats on stories with repairStories()', () => { + let storiesStore = { + dispatch: spy(), + state: { + pref: { + defaultFormat: { name: 'Default Format', version: '1.2.3' } + }, + storyFormat: { + formats: [ + { name: 'Default Format', version: '1.2.3' } + ] + }, + story: { + stories: [ + { id: 'not-a-real-id' } + ] + } + } + }; + + actions.repairStories(storiesStore); + expect(storiesStore.dispatch.calledWith( + 'UPDATE_STORY', + 'not-a-real-id', + { storyFormat: 'Default Format' } + )).to.be.true; + }); + + it('coerces old SugarCube references to their correct versions with repairStories()', () => { + let storiesStore = { + dispatch: spy(), + state: { + story: { + stories: [ + { + id: 'not-a-real-id', + storyFormat: 'SugarCube 1 (local/offline)' + }, + { + id: 'also-not-a-real-id', + storyFormat: 'SugarCube 2 (local/offline)' + } + ] + } + } + }; + + actions.repairStories(storiesStore); + expect(storiesStore.dispatch.calledWith( + 'UPDATE_STORY', + 'not-a-real-id', + { storyFormat: 'SugarCube', storyFormatVersion: '1.0.35' } + )).to.be.true; + expect(storiesStore.dispatch.calledWith( + 'UPDATE_STORY', + 'also-not-a-real-id', + { storyFormat: 'SugarCube', storyFormatVersion: '2.11.0' } + )).to.be.true; + }); + + it('sets format versions on stories with repairStories()', () => { + let storiesStore = { + dispatch: spy(), + state: { + pref: { + defaultFormat: { name: 'Default Format', version: '1.2.3' } + }, + storyFormat: { + formats: [ + { name: 'Default Format', version: '1.2.3' }, + { name: 'Default Format', version: '1.2.5' } + ] + }, + story: { + stories: [ + { + id: 'not-a-real-id', + storyFormat: 'Default Format' + }, + { + id: 'also-not-a-real-id', + storyFormat: 'Default Format', + storyFormatVersion: '' + } + ] + } + } + }; + + actions.repairStories(storiesStore); + expect(storiesStore.dispatch.calledWith( + 'UPDATE_STORY', + 'not-a-real-id', + { storyFormatVersion: '1.2.3' } + )).to.be.true; + expect(storiesStore.dispatch.calledWith( + 'UPDATE_STORY', + 'also-not-a-real-id', + { storyFormatVersion: '1.2.3' } + )).to.be.true; + }); + it('creates new links with createNewlyLinkedPassages()', () => { let storyStore = { dispatch: spy(), diff --git a/src/data/publish.js b/src/data/publish.js index 081267770..a3cead410 100644 --- a/src/data/publish.js +++ b/src/data/publish.js @@ -1,11 +1,15 @@ -// Publishes stories to HTML. +/* +Publishes stories to HTML. +*/ const { escape } = require('underscore'); const locale = require('../locale'); const publish = module.exports = { - // Publishes a story with a story format. The format *must* be loaded - // before this function is called. + /* + Publishes a story with a story format. The format *must* be loaded before + this function is called. + */ publishStoryWithFormat(appInfo, story, format, formatOptions, startId) { if (!format.properties || !format.properties.source) { @@ -14,17 +18,19 @@ const publish = module.exports = { let output = format.properties.source; - // We use function replacements to protect the data from accidental - // interactions with the special string replacement patterns. + /* + We use function replacements to protect the data from accidental + interactions with the special string replacement patterns. - // First, built-in placeholders. + First, built-in placeholders. + */ output = output.replace(/{{STORY_NAME}}/g, () => escape(story.name)); output = output.replace(/{{STORY_DATA}}/g, () => { return publish.publishStory(appInfo, story, formatOptions, startId); }); - // Then, format-defined placeholders. + /* Then, format-defined placeholders. */ if (format.properties.placeholders) { format.properties.placeholders.forEach(p => { @@ -39,29 +45,30 @@ const publish = module.exports = { return output; }, - // Publishes an archive of stories. + /* Publishes an archive of stories. */ publishArchive(stories, appInfo) { return stories.reduce( (output, story) => { - // Force publishing even if there is no start point set. + /* Force publishing even if there is no start point set. */ return output + publish.publishStory( appInfo, story, null, null, true ) + '\n\n'; }, - '' ); }, - // Does a "naked" publish of a story -- creating an HTML representation of - // it, but without any story format binding. + /* + Does a "naked" publish of a story -- creating an HTML representation of it, + but without any story format binding. + */ publishStory(appInfo, story, formatOptions, startId, startOptional) { startId = startId || story.startPassage; - // Verify that the start passage exists. + /* Verify that the start passage exists. */ if (!startOptional) { if (!startId) { @@ -78,7 +85,7 @@ const publish = module.exports = { } }; - // The id of the start passage as it is published (*not* a UUID). + /* The id of the start passage as it is published (*not* a UUID). */ let startLocalId; let passageData = ''; @@ -97,6 +104,7 @@ const publish = module.exports = { `creator-version="${escape(appInfo.version)}" ` + `ifid="${escape(story.ifid)}" ` + `format="${escape(story.storyFormat)}" ` + + `format-version="${escape(story.storyFormatVersion)}"` + `options="${escape(formatOptions)}" hidden>` + `` + @@ -106,9 +114,10 @@ const publish = module.exports = { ``; }, - // Publishes a passage to an HTML fragment. This takes a id argument - // because passages are numbered sequentially in published stories, not - // with a UUID. + /* + Publishes a passage to an HTML fragment. This takes a id argument because + passages are numbered sequentially in published stories, not with a UUID. + */ publishPassage(passage, localId) { return ` { expect(setup.calledOn(state.formats[0])).to.be.true; }); - it('updates a format with the CREATE_FORMAT mutation', () => { + it('updates a format with the UPDATE_FORMAT mutation', () => { storyFormat.mutations.CREATE_FORMAT(state, props); storyFormat.mutations.UPDATE_FORMAT( state, diff --git a/src/data/story.js b/src/data/story.js index 4a4191d63..9832711ba 100644 --- a/src/data/story.js +++ b/src/data/story.js @@ -193,7 +193,8 @@ const storyStore = module.exports = { snapToGrid: false, stylesheet: '', script: '', - storyFormat: '' + storyFormat: '', + storyFormatVersion: '' }, passageDefaults: { diff --git a/src/dialogs/formats/index.html b/src/dialogs/formats/index.html index ae1a0dcc7..998a18b03 100644 --- a/src/dialogs/formats/index.html +++ b/src/dialogs/formats/index.html @@ -10,7 +10,18 @@ {{'Story formats control the appearance and behavior of stories during play.' | say}}

- + + + + + + + + + + + +
{{ 'Use as Default' | say }}
@@ -18,7 +29,18 @@ {{'Proofing formats create a versions of stories tailored for editing and proofreading.' | say}}

- + + + + + + + + + + + +
{{ 'Use' | say }}
diff --git a/src/dialogs/formats/index.js b/src/dialogs/formats/index.js index 514c987f3..025b704de 100644 --- a/src/dialogs/formats/index.js +++ b/src/dialogs/formats/index.js @@ -1,4 +1,5 @@ const Vue = require('vue'); +const semverUtils = require('semver-utils'); const { createFormatFromUrl, loadFormat } = require('../../data/actions'); const locale = require('../../locale'); const notify = require('../../ui/notify'); @@ -33,35 +34,11 @@ module.exports = Vue.extend({ computed: { proofingFormats() { - let result = []; - - this.formatNames.forEach(name => { - const format = this.loadedFormats.find( - format => format.name === name - ); - - if (format && format.properties.proofing) { - result.push(format); - } - }); - - return result; + return this.loadedFormats.filter(format => format.properties.proofing); }, storyFormats() { - let result = []; - - this.formatNames.forEach(name => { - const format = this.loadedFormats.find( - format => format.name === name - ); - - if (format && !format.properties.proofing) { - result.push(format); - } - }); - - return result; + return this.loadedFormats.filter(format => !format.properties.proofing); } }, @@ -69,8 +46,8 @@ module.exports = Vue.extend({ // Loads the next pending format. loadNext() { - if (this.loadIndex < this.formatNames.length) { - this.loadFormat(this.formatNames[this.loadIndex]) + if (this.loadIndex < this.allFormats.length) { + this.loadFormat(this.allFormats[this.loadIndex].name, this.allFormats[this.loadIndex].version) .then(format => { this.loadedFormats.push(format); this.loadIndex++; @@ -109,28 +86,28 @@ module.exports = Vue.extend({ this.working = true; this.createFormatFromUrl(this.newFormatUrl) - .then(format => { - this.error = ''; - this.working = false; - this.loadNext(); - - // Show the tab the format will be loaded into. - - if (format.proofing) { - this.$refs.tabs.active = 1; - } - else { - this.$refs.tabs.active = 0; - } - }) - .catch(e => { - this.error = locale.say( - 'The story format at %1$s could not be added (%2$s).', - this.newFormatUrl, - e.message - ); - this.working = false; - }); + .then(format => { + this.error = ''; + this.working = false; + this.loadNext(); + + // Show the tab the format will be loaded into. + + if (format.proofing) { + this.$refs.tabs.active = 1; + } + else { + this.$refs.tabs.active = 0; + } + }) + .catch(e => { + this.error = locale.say( + 'The story format at %1$s could not be added (%2$s).', + this.newFormatUrl, + e.message + ); + this.working = false; + }); } }, @@ -158,9 +135,52 @@ module.exports = Vue.extend({ }, getters: { - formatNames: state => state.storyFormat.formats.map( - format => format.name - ), + allFormats: state => { + var result = state.storyFormat.formats.map( + format => ({ name: format.name, version: format.version }) + ); + + result.sort((a, b) => { + if (a.name < b.name) { + return -1; + } + + if (a.name > b.name) { + return 1; + } + + const aVersion = semverUtils.parse(a.version); + const bVersion = semverUtils.parse(b.version); + + if (aVersion.major > bVersion.major) { + return -1; + } + else if (aVersion.major < bVersion.major) { + return 1; + } + else { + if (aVersion.minor > bVersion.minor) { + return -1; + } + else if (aVersion.minor < bVersion.minor) { + return 1; + } + else { + if (aVersion.patch > bVersion.patch) { + return -1; + } + else if (aVersion.patch < bVersion.patch) { + return 1; + } + else { + return 0; + } + } + } + }); + + return result; + }, defaultFormatPref: state => state.pref.defaultFormat, proofingFormatPref: state => state.pref.proofingFormat } diff --git a/src/dialogs/formats/item.html b/src/dialogs/formats/item.html index d9e8ccf5f..bf05fe5bb 100644 --- a/src/dialogs/formats/item.html +++ b/src/dialogs/formats/item.html @@ -1,32 +1,15 @@ -
-
- - + + +
-
- - - - {{ format.name.slice(0,2) }} - -
-

{{ format.name }} {{ format.properties.version }} - -

- -

- {{{ format.properties.description }}} -

- -

- {{{ license }}} -

-
-
-
+ + {{ format.name }} {{ format.properties.version }} + + {{{ author }}} + + + + + + diff --git a/src/dialogs/formats/item.js b/src/dialogs/formats/item.js index 23699255d..a164401d2 100644 --- a/src/dialogs/formats/item.js +++ b/src/dialogs/formats/item.js @@ -15,23 +15,12 @@ module.exports = Vue.extend({ computed: { isDefault() { if (this.format.properties.proofing) { - return this.proofingFormatPref === this.format.name; + return this.proofingFormatPref.name === this.format.name && + this.proofingFormatPref.version === this.format.version; } - return this.defaultFormatPref === this.format.name; - }, - - imageSrc() { - const path = this.format.url.replace(/\/[^\/]*?$/, ''); - - return path + '/' + this.format.properties.image; - }, - - license() { - return this.format.properties.license ? locale.say( - /* L10n: %s is the name of a software license. */ - 'License: %s', this.format.properties.license - ) : ''; + return this.defaultFormatPref.name === this.format.name && + this.defaultFormatPref.version === this.format.version; }, author() { @@ -58,10 +47,16 @@ module.exports = Vue.extend({ setDefaultFormat() { if (this.format.properties.proofing) { - this.setPref('proofingFormat', this.format.name); + this.setPref( + 'proofingFormat', + { name: this.format.name, version: this.format.version } + ); } else { - this.setPref('defaultFormat', this.format.name); + this.setPref( + 'defaultFormat', + { name: this.format.name, version: this.format.version } + ); } }, }, diff --git a/src/dialogs/formats/item.less b/src/dialogs/formats/item.less index df2958940..7623d1a77 100644 --- a/src/dialogs/formats/item.less +++ b/src/dialogs/formats/item.less @@ -1,33 +1,11 @@ -.format-item { - clear: both; - .buttons { - float:right; +tr.format-item { + img { + height: 2rem; + width: auto; } - .placeholder { - height: 1.5em; /* 6em / 4 */ - width: 1.5em; - margin-right: 0.375em; /* 1.5em / 4 */ - padding-top: 0.125em; - line-height: 2.5em; + + .selector { + vertical-align: middle; text-align: center; - background-color: hsl(0, 0%, 80%); - color: hsl(0, 0%, 90%); - font: 4em "Nunito Light", Helvetica, sans-serif; - border-radius: 0.25em; - box-sizing: border-box; - overflow: hidden; - } - .text { - margin-left: 7.5em; - p { - font-size: 90%; - } - } - img, .placeholder { - float: left; - margin-right: 1.5em; - } - img { - width: 6em; } -} \ No newline at end of file +} diff --git a/src/dialogs/story-format/detail.html b/src/dialogs/story-format/detail.html new file mode 100644 index 000000000..12ddcf7d8 --- /dev/null +++ b/src/dialogs/story-format/detail.html @@ -0,0 +1,27 @@ +
+
+ + +
+ + {{ format.name.slice(0,2) }} +
+ +

+ {{ format.name }} {{ format.version }} + +
+ by {{{ format.properties.author }}} +
+ + +
+ License: {{{ format.properties.license }}} +
+

+ +

+ {{{ format.properties.description }}} +

+
+
diff --git a/src/dialogs/story-format/detail.js b/src/dialogs/story-format/detail.js new file mode 100644 index 000000000..6d3f0950e --- /dev/null +++ b/src/dialogs/story-format/detail.js @@ -0,0 +1,26 @@ +/* +Shows detail about a selected format. +*/ + +const Vue = require('vue'); + +module.exports = Vue.extend({ + props: { + working: true, + format: null + }, + + computed: { + /* + Calculates the image source relative to the format's path. + */ + + imageSrc() { + const path = this.format.url.replace(/\/[^\/]*?$/, ''); + + return path + '/' + this.format.properties.image; + } + }, + + template: require('./detail.html') +}); diff --git a/src/dialogs/story-format/detail.less b/src/dialogs/story-format/detail.less new file mode 100644 index 000000000..686ce2ce2 --- /dev/null +++ b/src/dialogs/story-format/detail.less @@ -0,0 +1,28 @@ +@import '../../common/colors.less'; +@import '../../common/metrics.less'; + +.format-detail { + display: flex; + align-items: center; + border-left: @len-hairline solid @color-form-line; + margin-left: @len-large; + padding-left: @len-large; + + .icon { + float: left; + margin-right: @len-medium; + padding-bottom: @len-small; + + img { + width: 5rem; + } + } + + .main-info { + margin-top: 0; + } + + .detail { + clear: both; + } +} diff --git a/src/dialogs/story-format/index.html b/src/dialogs/story-format/index.html index 2f3f37047..0e9d04e79 100644 --- a/src/dialogs/story-format/index.html +++ b/src/dialogs/story-format/index.html @@ -5,11 +5,14 @@ {{ 'A story format controls the appearance and behavior of your story during play.' | say }}

-
- -
-

{{ 'Loading...' | say }}

+ +
+
+ +
+ +
diff --git a/src/dialogs/story-format/index.js b/src/dialogs/story-format/index.js index 9bab2c098..37251b4f9 100644 --- a/src/dialogs/story-format/index.js +++ b/src/dialogs/story-format/index.js @@ -2,6 +2,7 @@ const Vue = require('vue'); const { loadFormat } = require('../../data/actions'); +const semverUtils = require('semver-utils'); module.exports = Vue.extend({ template: require('./index.html'), @@ -9,7 +10,7 @@ module.exports = Vue.extend({ data: () => ({ loadIndex: 0, loadedFormats: [], - storyId: '' + storyId: '', }), computed: { @@ -17,8 +18,15 @@ module.exports = Vue.extend({ return this.allStories.find(story => story.id === this.storyId); }, + selectedFormat() { + return this.loadedFormats.find( + format => format.name === this.story.storyFormat && + format.version === this.story.storyFormatVersion + ); + }, + working() { - return this.loadIndex < this.formatNames.length; + return this.loadIndex < this.allFormats.length; } }, @@ -28,7 +36,9 @@ module.exports = Vue.extend({ return; } - this.loadFormat(this.formatNames[this.loadIndex]) + const nextFormat = this.allFormats[this.loadIndex]; + + this.loadFormat(nextFormat.name, nextFormat.version) .then(format => { if (!format.properties.proofing) { this.loadedFormats.push(format); @@ -51,13 +61,57 @@ module.exports = Vue.extend({ getters: { allStories: state => state.story.stories, - formatNames: state => state.storyFormat.formats.map( - format => format.name - ) + allFormats: state => { + var result = state.storyFormat.formats.map( + format => ({ name: format.name, version: format.version }) + ); + + result.sort((a, b) => { + if (a.name < b.name) { + return -1; + } + + if (a.name > b.name) { + return 1; + } + + const aVersion = semverUtils.parse(a.version); + const bVersion = semverUtils.parse(b.version); + + if (aVersion.major > bVersion.major) { + return -1; + } + else if (aVersion.major < bVersion.major) { + return 1; + } + else { + if (aVersion.minor > bVersion.minor) { + return -1; + } + else if (aVersion.minor < bVersion.minor) { + return 1; + } + else { + if (aVersion.patch > bVersion.patch) { + return -1; + } + else if (aVersion.patch < bVersion.patch) { + return 1; + } + else { + return 0; + } + } + } + }); + + return result; + } } }, components: { + 'format-detail': require('./detail'), 'format-item': require('./item'), 'modal-dialog': require('../../ui/modal-dialog') } diff --git a/src/dialogs/story-format/index.less b/src/dialogs/story-format/index.less new file mode 100644 index 000000000..54c834665 --- /dev/null +++ b/src/dialogs/story-format/index.less @@ -0,0 +1,12 @@ +@import '../../common/metrics.less'; + +#storyFormatModal { + .formats { + display: flex; + align-items: stretch; + + .items { + flex-grow: 1; + } + } +} diff --git a/src/dialogs/story-format/item.html b/src/dialogs/story-format/item.html index 50b30889c..0a30fc509 100644 --- a/src/dialogs/story-format/item.html +++ b/src/dialogs/story-format/item.html @@ -1,26 +1,4 @@ -
- - - - - - {{ format.name.slice(0,2) }} - -
-

- {{ format.name }} {{ format.properties.version }} - -

- -

- {{{ format.properties.description }}} -

- -

- {{{ format.properties.license }}} -

-
-
+

+ + +

diff --git a/src/dialogs/story-format/item.js b/src/dialogs/story-format/item.js index 90068d6c9..753652879 100644 --- a/src/dialogs/story-format/item.js +++ b/src/dialogs/story-format/item.js @@ -2,6 +2,7 @@ // choose it. const Vue = require('vue'); +const semverUtils = require('semver-utils'); const { updateStory } = require('../../data/actions'); module.exports = Vue.extend({ @@ -20,20 +21,21 @@ module.exports = Vue.extend({ }, computed: { - imageSrc() { - const path = this.format.url.replace(/\/[^\/]*?$/, ''); - - return path + '/' + this.format.properties.image; - }, - selected() { - return this.story.storyFormat === this.format.name; + return this.story.storyFormat === this.format.name && + this.story.storyFormatVersion === this.format.version; } }, methods: { select() { - this.updateStory(this.story.id, { storyFormat: this.format.name }); + this.updateStory( + this.story.id, + { + storyFormat: this.format.name, + storyFormatVersion: this.format.version + } + ); } }, diff --git a/src/dialogs/story-format/item.less b/src/dialogs/story-format/item.less index ff44495f1..3ef5478c5 100644 --- a/src/dialogs/story-format/item.less +++ b/src/dialogs/story-format/item.less @@ -1,93 +1,3 @@ #storyFormatModal .format-item { - input[type="radio"] { - float: left; - font-size: 2.5em; - padding: 0 0.5em 0 0; - } - - img { - width: 6em; - float: right; - } - - .text { - margin-left: 2.5em; - } -} - -#storyFormatModal .formats button.select -{ - float: left; - font-size: 2.5em; - padding: 0 0.5em 0 0; - margin: 0; -} - -#storyFormatModal .formats button.select:active, -#storyFormatModal .formats button.select.active -{ - box-shadow: none; - background: transparent; -} - -#storyFormatModal .formats .select .showActive -{ - display: none; -} - -#storyFormatModal .formats .select.active .showActive -{ - display: inline; -} - -#storyFormatModal .formats .select.active .hideActive -{ - display: none; -} - -#storyEditView .formatDetail img, -#storyEditView .formatDetail .placeholder -{ - width: 6em; - float: left; - margin-right: 1.5em; -} - -#storyEditView .formatDetail img, -#storyEditView .formatDetail .placeholder -{ - float: right; - margin-left: 1.5em; -} - -#storyEditView .formatDetail .placeholder -{ - height: 1.5em; /* 6em / 4 */ - width: 1.5em; - margin-right: 0.375em; /* 1.5em / 4 */ - padding-top: 0.125em; - line-height: 2.5em; - text-align: center; - background-color: hsl(0, 0%, 80%); - color: hsl(0, 0%, 90%); - font: 4em "Nunito Light", Helvetica, sans-serif; - border-radius: 0.25em; - box-sizing: border-box; - overflow: hidden; -} - -#storyEditView .formatDetail .text -{ - margin-left: 7.5em; -} - -#storyEditView .formatDetail .text -{ - margin-right: 7.5em; - margin-left: 3em; -} - -#storyEditView .formatDetail .description -{ - font-size: 90%; + white-space: nowrap; } diff --git a/src/editors/passage/index.js b/src/editors/passage/index.js index 1f0fd2e0e..90118d5b5 100644 --- a/src/editors/passage/index.js +++ b/src/editors/passage/index.js @@ -183,7 +183,10 @@ module.exports = Vue.extend({ */ if (this.$options.storyFormat) { - this.loadFormat(this.$options.storyFormat).then((format) => { + this.loadFormat( + this.$options.storyFormat.name, + this.$options.storyFormat.version + ).then(format => { const modeName = format.name.toLowerCase(); if (modeName in CodeMirror.modes) { diff --git a/src/locale/po/sv.po b/src/locale/po/sv.po index e214924c5..c759c7e40 100644 --- a/src/locale/po/sv.po +++ b/src/locale/po/sv.po @@ -1160,4 +1160,4 @@ msgstr[1] "%d historierna importerades." msgid "%d Story" msgid_plural "%d Stories" msgstr[0] "%d Historia" -msgstr[1] "%d Historierna" +msgstr[1] "%d Historier" diff --git a/src/story-edit-view/index.js b/src/story-edit-view/index.js index a1fbcc430..f956ea849 100644 --- a/src/story-edit-view/index.js +++ b/src/story-edit-view/index.js @@ -270,9 +270,9 @@ module.exports = Vue.extend({ 'passage-drag'(xOffset, yOffset) { if (this.story.snapToGrid) { this.screenDragOffsetX = Math.round(xOffset / this.gridSize) * - this.gridSize * this.story.zoom; + this.gridSize; this.screenDragOffsetY = Math.round(yOffset / this.gridSize) * - this.gridSize * this.story.zoom; + this.gridSize; } else { this.screenDragOffsetX = xOffset; @@ -289,10 +289,8 @@ module.exports = Vue.extend({ this.screenDragOffsetY = 0; if (this.story.snapToGrid) { - xOffset = Math.round(xOffset / this.gridSize) * this.gridSize * - this.story.zoom; - yOffset = Math.round(yOffset / this.gridSize) * this.gridSize * - this.story.zoom; + xOffset = Math.round(xOffset / this.gridSize) * this.gridSize; + yOffset = Math.round(yOffset / this.gridSize) * this.gridSize; } this.$broadcast('passage-drag-complete', xOffset, yOffset); diff --git a/src/story-edit-view/passage-item/index.js b/src/story-edit-view/passage-item/index.js index fdc3cc2c6..02276ff88 100644 --- a/src/story-edit-view/passage-item/index.js +++ b/src/story-edit-view/passage-item/index.js @@ -153,7 +153,10 @@ module.exports = Vue.extend({ origin: this.$el }, store: this.$store, - storyFormat: this.parentStory.storyFormat, + storyFormat: { + name: this.parentStory.storyFormat, + version: this.parentStory.storyFormatVersion + } }) .$mountTo(document.body) .then(() => { diff --git a/src/story-edit-view/story-toolbar/story-menu/index.js b/src/story-edit-view/story-toolbar/story-menu/index.js index a3abe4ba5..838fd006d 100644 --- a/src/story-edit-view/story-toolbar/story-menu/index.js +++ b/src/story-edit-view/story-toolbar/story-menu/index.js @@ -67,12 +67,10 @@ module.exports = Vue.extend({ }, publishStory() { - const formatName = this.story.format || this.defaultFormatName; - const format = this.allFormats.find( - format => format.name === formatName - ); - - this.loadFormat(formatName).then(() => { + this.loadFormat( + this.story.storyFormat, + this.story.storyFormatVersion + ).then(format => { save( publishStoryWithFormat(this.appInfo, this.story, format), this.story.name + '.html' diff --git a/src/story-list-view/story-item/item-menu/index.js b/src/story-list-view/story-item/item-menu/index.js index 4c713a3b0..a963c0314 100644 --- a/src/story-list-view/story-item/item-menu/index.js +++ b/src/story-list-view/story-item/item-menu/index.js @@ -58,12 +58,7 @@ module.exports = Vue.extend({ **/ publish() { - const formatName = this.story.format || this.defaultFormatName; - const format = this.allFormats.find( - format => format.name === formatName - ); - - this.loadFormat(formatName).then(() => { + this.loadFormat(this.story.storyFormat).then(format => { save( publishStoryWithFormat(this.appInfo, this.story, format), this.story.name + '.html' @@ -149,7 +144,7 @@ module.exports = Vue.extend({ getters: { allFormats: state => state.storyFormat.formats, appInfo: state => state.appInfo, - defaultFormatName: state => state.pref.defaultFormat + defaultFormat: state => state.pref.defaultFormat, } } }); diff --git a/src/ui/drop-down/index.js b/src/ui/drop-down/index.js index d8a563c24..e6bb7ea23 100644 --- a/src/ui/drop-down/index.js +++ b/src/ui/drop-down/index.js @@ -35,6 +35,15 @@ module.exports = Vue.extend({ classes: this.class, constrainToWindow: true, constrainToScrollParent: false, + tetherOptions: { + constraints: [ + { + to: 'window', + attachment: 'together', + pin: true + } + ] + } }); // Close the dropdown when one of its menu items is clicked. diff --git a/src/ui/modal-dialog/index.js b/src/ui/modal-dialog/index.js index c3a3d0155..2a7903386 100644 --- a/src/ui/modal-dialog/index.js +++ b/src/ui/modal-dialog/index.js @@ -53,10 +53,10 @@ const ModalDialog = module.exports = Vue.extend({ components. */ this.$broadcast('transition-entered'); - dialog.removeEventListener('transitionend', notifier); + dialog.removeEventListener('animationend', notifier); }; - dialog.addEventListener('transitionend', notifier); + dialog.addEventListener('animationend', notifier); }, destroyed() { diff --git a/src/ui/tables.less b/src/ui/tables.less new file mode 100644 index 000000000..aeef0c06c --- /dev/null +++ b/src/ui/tables.less @@ -0,0 +1,38 @@ +@import '../common/colors.less'; +@import '../common/metrics.less'; + +table { + border-collapse: collapse; + width: 100%; +} + +tbody tr { + border-top: 1px solid @color-form-line; + + table.unlined & { + border-top-color: transparent; + } +} + +th { + font-weight: normal; + opacity: 0.8; + white-space: nowrap; +} + +td { + padding-top: @len-small; + padding-bottom: @len-small; + + :first-child { + margin-top: 0; + } + + :last-child { + margin-bottom: 0; + } + + table.unlined & { + padding: @len-hairline; + } +} diff --git a/story-formats/Paperthin/format.js b/story-formats/Paperthin/format.js deleted file mode 100644 index 68c62bb1c..000000000 --- a/story-formats/Paperthin/format.js +++ /dev/null @@ -1 +0,0 @@ -window.storyFormat({"name":"Paperthin","version":"1.0","description":"The default proofing format for Twine 2. Icon designed by Simon Child from the Noun Project","author":"Chris Klimas","image":"icon.svg","url":"http://twinery.org/","license":"ZLib/Libpng","proofing":true,"source":"\n\n\n{{STORY_NAME}}\n\n\n\n\n\n\n\n

{{STORY_NAME}}\n

\n{{STORY_DATA}}\n\n\n\n\n"}); \ No newline at end of file diff --git a/story-formats/Harlowe/format.js b/story-formats/harlowe-1.2.3/format.js similarity index 100% rename from story-formats/Harlowe/format.js rename to story-formats/harlowe-1.2.3/format.js diff --git a/story-formats/Harlowe/icon.svg b/story-formats/harlowe-1.2.3/icon.svg similarity index 100% rename from story-formats/Harlowe/icon.svg rename to story-formats/harlowe-1.2.3/icon.svg diff --git a/story-formats/harlowe-2.0.0/format.js b/story-formats/harlowe-2.0.0/format.js new file mode 100644 index 000000000..5177d5e3a --- /dev/null +++ b/story-formats/harlowe-2.0.0/format.js @@ -0,0 +1 @@ +window.storyFormat({"name":"Harlowe","version":"2.0.0","author":"Leon Arnott","description":"The default story format for Twine 2. See its documentation.","image":"icon.svg","url":"http://twinery.org/","proofing":false,"source":"\n\n\n\n{{STORY_NAME}}\n\n\n\n\n\n\n\n{{STORY_DATA}}\n\n\n\n\n\n\n","setup": function(){"use strict";}}); \ No newline at end of file diff --git a/story-formats/harlowe-2.0.0/icon.svg b/story-formats/harlowe-2.0.0/icon.svg new file mode 100644 index 000000000..cbad87815 --- /dev/null +++ b/story-formats/harlowe-2.0.0/icon.svg @@ -0,0 +1,78 @@ + + + + + + + + + + image/svg+xml + + + + + + + H + + + diff --git a/story-formats/paperthin-1.0.0/format.js b/story-formats/paperthin-1.0.0/format.js new file mode 100644 index 000000000..aaec67f18 --- /dev/null +++ b/story-formats/paperthin-1.0.0/format.js @@ -0,0 +1 @@ +window.storyFormat({"name":"Paperthin","version":"1.0.0","description":"The default proofing format for Twine 2. Icon designed by Simon Child from the Noun Project","author":"Chris Klimas","image":"icon.svg","url":"http://twinery.org/","license":"ZLib/Libpng","proofing":true,"source":"\n\n\n{{STORY_NAME}}\n\n\n\n\n\n\n\n

{{STORY_NAME}}\n

\n{{STORY_DATA}}\n\n\n\n\n"}); diff --git a/story-formats/Paperthin/icon.svg b/story-formats/paperthin-1.0.0/icon.svg similarity index 100% rename from story-formats/Paperthin/icon.svg rename to story-formats/paperthin-1.0.0/icon.svg diff --git a/story-formats/Snowman/format.js b/story-formats/snowman-1.3.0/format.js similarity index 100% rename from story-formats/Snowman/format.js rename to story-formats/snowman-1.3.0/format.js diff --git a/story-formats/Snowman/icon.svg b/story-formats/snowman-1.3.0/icon.svg similarity index 100% rename from story-formats/Snowman/icon.svg rename to story-formats/snowman-1.3.0/icon.svg diff --git a/story-formats/SugarCube/LICENSE b/story-formats/sugarcube-1.0.35/LICENSE similarity index 100% rename from story-formats/SugarCube/LICENSE rename to story-formats/sugarcube-1.0.35/LICENSE diff --git a/story-formats/SugarCube/format.js b/story-formats/sugarcube-1.0.35/format.js similarity index 100% rename from story-formats/SugarCube/format.js rename to story-formats/sugarcube-1.0.35/format.js diff --git a/story-formats/SugarCube/icon.svg b/story-formats/sugarcube-1.0.35/icon.svg similarity index 100% rename from story-formats/SugarCube/icon.svg rename to story-formats/sugarcube-1.0.35/icon.svg diff --git a/story-formats/sugarcube-2.11.0/LICENSE b/story-formats/sugarcube-2.11.0/LICENSE new file mode 100755 index 000000000..16c827dd2 --- /dev/null +++ b/story-formats/sugarcube-2.11.0/LICENSE @@ -0,0 +1,23 @@ + +Copyright (c) 2013-2016 Thomas Michael Edwards . +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/story-formats/sugarcube-2.11.0/format.js b/story-formats/sugarcube-2.11.0/format.js new file mode 100755 index 000000000..7e59bab70 --- /dev/null +++ b/story-formats/sugarcube-2.11.0/format.js @@ -0,0 +1 @@ +window.storyFormat({"name":"SugarCube","version":"2.11.0","description":"A Twine 2 port of the Twine 1 story format by the same name. See its documentation.","author":"Thomas Michael Edwards","image":"icon.svg","url":"http://www.motoslave.net/sugarcube/","license":"Simplified BSD License","proofing":false,"source":"\n\n\n\n{{STORY_NAME}}\n\n\n\n\n\n\n\n\n\n\n\n\n\t
\n\t\t

\n\t\t

Apologies! You are using an outdated browser which lacks required capabilities. Please upgrade your browser to improve your experience.

\n\t\t

Initializing. Please wait…

\n\t
\n\t\n\t\n\n\n"}); diff --git a/story-formats/sugarcube-2.11.0/icon.svg b/story-formats/sugarcube-2.11.0/icon.svg new file mode 100755 index 000000000..2893c9df0 --- /dev/null +++ b/story-formats/sugarcube-2.11.0/icon.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + Open Clip Art Library + + + Sugar Cube icon + 2010-10-10T11:46:52 + A sugar cube. + http://openclipart.org/detail/89407/sugar-cube-icon-by-jhnri4 + + + jhnri4 + + + + + SVG + block + clip art + clipart + cube + icon + sugar + white + + + + + + + + + + + diff --git a/tests/selenium/helpers.js b/tests/selenium/helpers.js deleted file mode 100644 index 7e0525064..000000000 --- a/tests/selenium/helpers.js +++ /dev/null @@ -1,40 +0,0 @@ -'use strict'; -var until = require('selenium-webdriver').until; - -var helpers = module.exports = { - testUrl: 'file://' + - __dirname.replace('/tests/selenium', '') + - '/build/standalone/index.html', - - createStory: function(dr, dontReturn) { - dr.get(helpers.testUrl + '#stories'); - - var addButton = dr.findElement({ css: '.addStory' }); - var bubble = addButton - .findElement({ xpath: '../..' }) - .findElement({ css: '.bubble '}); - addButton.click(); - - dr.wait(until.elementIsVisible(bubble)) - .then(function() { - var nameField = bubble.findElement({ css: '.newName' }); - var addSubmitButton = bubble.findElement({ css: '.add' }); - - nameField.sendKeys(helpers.shortUni); - addSubmitButton.click(); - - dr.wait(until.elementLocated({ css: '#storyEditView' })); - - if (!dontReturn) { - dr.wait(until.elementLocated({ css: '#storyEditView' })); - dr.get(helpers.testUrl + '#stories'); - dr.wait(until.elementLocated({ css: '#storyListView' })); - }; - }); - }, - // jscs:disable maximumLineLength - shortUni: 'A good day, World! Schönen Tag, Welt! Une bonne journée, tout le monde! يوم جيد، العالم 좋은 일, 세계! Một ngày tốt lành, thế giới! こんにちは、世界!', - - longUni: 'A good day, World! Schönen Tag, Welt! Une bonne journée, tout le monde! يوم جيد، العالم 좋은 일, 세계! Một ngày tốt lành, thế giới! こんにちは、世界! Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec non nisi posuere, tincidunt risus ut, sollicitudin tortor. In ut neque nibh. Vestibulum ac dui eget ligula blandit cursus id eget massa. In justo magna, facilisis ac lorem in, commodo hendrerit turpis. Quisque sed lacus rhoncus, volutpat arcu sit amet, tincidunt metus. Donec hendrerit diam at volutpat dapibus. Donec efficitur imperdiet sapien, ac lacinia felis facilisis eu. Integer bibendum nibh a turpis condimentum rhoncus. Cras condimentum lobortis aliquet. Donec ultricies metus vitae nisl pellentesque rhoncus. Nulla blandit efficitur ante, vel lacinia est porttitor a. Fusce erat sem, pulvinar et pulvinar sit amet, rhoncus eget sem. Suspendisse at ipsum sit amet est facilisis porta. Nam sit amet enim interdum, eleifend augue sit amet, mollis dolor. Quisque iaculis, arcu et consequat congue, justo augue mollis metus, eu fringilla ligula est vel lacus. Vestibulum sit amet venenatis massa. Suspendisse dui velit, dictum eu mollis vitae, mollis id risus. Nullam eleifend ultricies nibh in volutpat. Donec ac imperdiet purus. Nullam luctus, mi a iaculis fermentum, purus magna tincidunt dui, sit amet blandit neque purus sed erat. Mauris feugiat non risus et sollicitudin. Vestibulum ut semper ipsum. Nulla diam nibh, condimentum eget auctor non, dapibus ut libero. In sit amet sem laoreet, consectetur sapien eu, iaculis metus. Sed ut interdum est, ornare scelerisque lectus. Maecenas nec libero sit amet justo tincidunt pulvinar. Sed euismod, risus id pulvinar aliquet, ipsum ipsum malesuada purus, eu varius ligula massa et massa. Curabitur venenatis tempor augue, sed dictum erat blandit et. Nulla posuere commodo lectus, vel feugiat diam commodo ac. Nam consectetur velit sed congue bibendum. Donec eu lectus tortor. Mauris lacinia, massa a dictum placerat, massa nunc venenatis velit, vel mattis eros dui sit amet ipsum. Quisque efficitur, felis ac venenatis maximus, sem mi aliquet mi, sit amet luctus nisl leo vel libero. Nunc a dictum ante. Nam nec enim at justo pellentesque vehicula. Morbi sollicitudin gravida consequat. Duis vulputate, diam vitae venenatis volutpat, quam sem faucibus neque, at bibendum tellus felis id lacus. Vivamus aliquet, ipsum eu dictum bibendum, ligula mi ultricies risus, et suscipit magna velit rutrum sapien. Aliquam ac justo nisi. Pellentesque sed ipsum leo. Ut vitae iaculis sem. Proin ut ante sed libero ultrices tempor nec a lacus. Sed nunc enim, dapibus quis arcu in, rhoncus pretium tellus. Nam tristique ligula magna, nec consequat sem ultrices a. In semper tellus at nisi hendrerit efficitur. Nullam laoreet porta rhoncus. Sed non magna in nibh feugiat lacinia. Etiam vel tortor sagittis libero consequat ultricies sed quis turpis. Pellentesque vel felis at orci egestas mattis ut ut diam. Ut a libero eget sem malesuada blandit. Proin neque ipsum, dictum ut tortor ac, finibus sagittis risus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas feugiat a libero nec fermentum. Phasellus pretium dolor in felis rutrum, in posuere diam sollicitudin. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Vestibulum posuere vehicula tortor, vitae dapibus augue lacinia id. Mauris rutrum tincidunt dui, vitae accumsan turpis pulvinar in. Proin a eros porttitor, placerat est mollis, laoreet lorem. Aliquam varius nisl augue, sit amet rhoncus lectus posuere ut. Vivamus vulputate, mauris aliquet lobortis finibus, neque sem malesuada turpis, ut imperdiet nibh erat ultricies erat. Aenean in diam a neque tempus tincidunt. Nulla pretium urna nulla, nec vulputate nunc congue sit amet. Mauris at suscipit neque. Nullam non suscipit mi. Aliquam erat volutpat. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla aliquam gravida congue. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Duis sagittis mauris mattis ultrices consequat. Nam quis mauris sit amet elit ornare vehicula ut in lorem. Quisque pulvinar finibus sem, ut dapibus ex maximus sed. Quisque consectetur fringilla ante quis egestas. Sed commodo sit amet turpis et rutrum. Aliquam semper magna sagittis magna sollicitudin sollicitudin. Cras sodales enim vitae lacus tempor tristique. Donec auctor, nisl convallis sodales condimentum, velit tortor efficitur tortor, et tempor nulla sapien eu odio. Suspendisse efficitur mi sed libero facilisis, at suscipit mi condimentum. Ut a dapibus felis. Mauris gravida sapien justo. Vivamus ac nunc vel est condimentum posuere sed vitae massa. Praesent elementum, magna nec feugiat iaculis, lectus turpis molestie felis, quis pellentesque nunc ipsum quis turpis. Nam a massa nec dolor porttitor sagittis eget et odio. Pellentesque dui mi, placerat nec pulvinar vel, finibus sit amet erat. Maecenas nec enim et urna rutrum ultrices. Integer pharetra iaculis dui, a blandit libero placerat sit amet. Donec aliquam sagittis lacus vel placerat. Aliquam quis enim sit amet turpis vestibulum tristique vel et lacus. Aenean vehicula placerat laoreet. Mauris ornare neque urna, id pharetra nibh volutpat at. Ut in erat massa. Suspendisse potenti. Nunc hendrerit eget ante sed ullamcorper. Pellentesque viverra consectetur magna, nec sollicitudin enim maximus eu. Maecenas sagittis malesuada velit in aliquet. Vestibulum elementum elementum felis. Etiam sodales facilisis cursus. Duis sodales, tellus ut rhoncus fringilla, justo ipsum aliquam erat, vel feugiat eros orci efficitur neque. Donec ac pharetra elit. Morbi eget felis eu velit commodo ultrices. Fusce consectetur leo vestibulum tortor pretium, at commodo nisl tincidunt. Vestibulum eu felis eu nisl molestie imperdiet in vel urna. Fusce quis justo nec metus blandit feugiat sit amet sit amet est. Sed vestibulum dignissim velit, et hendrerit nisi aliquam vel. Proin ac commodo nunc. Aliquam vehicula nunc a nisl facilisis faucibus. Etiam venenatis eu eros vitae fermentum. Ut diam turpis, viverra ut vestibulum id, viverra nec dui. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Cras velit neque, maximus ac quam ut, scelerisque suscipit mauris. Proin lacinia neque at euismod pretium. Vestibulum consectetur bibendum turpis. Mauris porta magna magna. Suspendisse porttitor vel velit eget euismod. Quisque lacus urna, pellentesque vitae ornare sit amet, mattis non dui. Sed odio dui, fringilla ut sagittis quis, hendrerit in metus. Phasellus placerat, lectus vitae euismod tristique, est massa pharetra ante, non porttitor diam diam in lorem. Praesent eu nisl dapibus, ullamcorper magna id, molestie est. Etiam.', - // jscs:enable maximumLineLength -}; diff --git a/tests/selenium/story-edit.js b/tests/selenium/story-edit.js deleted file mode 100644 index 96dabf7a6..000000000 --- a/tests/selenium/story-edit.js +++ /dev/null @@ -1,421 +0,0 @@ -'use strict'; -var _ = require('underscore'); -var assert = require('selenium-webdriver/testing/assert'); -var firefox = require('selenium-webdriver/firefox'); -var key = require('selenium-webdriver').Key; -var phantomjs = require('selenium-webdriver/phantomjs'); -var test = require('selenium-webdriver/testing'); -var until = require('selenium-webdriver').until; -var helpers = require('./helpers'); - -test.describe('StoryEditView', function() { - var dr; - this.timeout(10000); - - test.beforeEach(function() { - dr = new firefox.Driver(); - dr.manage().window().setSize(1024, 768); - helpers.createStory(dr, true); - }); - - test.afterEach(function() { - dr.quit(); - }); - - function setCMText(dr, selector, text) { - dr.executeScript('document.querySelector(\'' + selector + - ' .CodeMirror\').CodeMirror.setValue(\'' + - text + '\')'); - }; - - function getCMText(dr, selector) { - return dr.executeScript('return document.querySelector(\'' + selector + - ' .CodeMirror\').CodeMirror.getValue()'); - }; - - test.it('Displays the story title', function() { - assert(dr.findElement({ css: 'button.storyName' }).getText()) - .equalTo(helpers.shortUni); - }); - - test.it('Returns to the story list with the home button', function() { - dr.findElement({ css: 'a.home' }).click(); - dr.wait(until.elementLocated({ css: '#storyListView' })); - }); - - test.it('Creates a passage with the Create Passage button', function() { - assert( - dr.isElementPresent({ css: '.passages .passage:nth-of-type(2)' }) - ).isFalse(); - dr.findElement({ css: 'button.addPassage' }).click(); - assert( - dr.isElementPresent({ css: '.passages .passage:nth-of-type(2)' }) - ).isTrue(); - }); - - test.it('Edits a passage by double-clicking it', function() { - var modal = dr.findElement({ css: '#passageEditModal' }); - - dr.actions() - .doubleClick(dr.findElement({ css: '.passages .passage' })) - .perform(); - dr.wait(until.elementIsVisible(modal)); - dr.findElement({ css: '#passageEditModal .close' }).click(); - dr.wait(until.elementIsNotVisible(modal)); - }); - - test.it('Edits a passage by clicking its edit button', function() { - var modal = dr.findElement({ css: '#passageEditModal' }); - - dr.actions() - .mouseMove(dr.findElement({ css: '.passages .passage' })) - .perform(); - dr.findElement({ css: '.passages .passage .edit' }).click(); - dr.wait(until.elementIsVisible(modal)); - dr.findElement({ css: '#passageEditModal .close' }).click(); - dr.wait(until.elementIsNotVisible(modal)); - }); - - test.it('Saves changes to passage text', function() { - var modal = dr.findElement({ css: '#passageEditModal' }); - - dr.actions() - .doubleClick(dr.findElement({ css: '.passages .passage' })) - .perform(); - dr.wait(until.elementIsVisible(modal)); - setCMText(dr, '#passageEditModal', helpers.longUni); - - dr.findElement({ css: '#passageEditModal .close' }).click(); - dr.wait(until.elementIsNotVisible(modal)); - - dr.navigate().refresh(); - modal = dr.findElement({ css: '#passageEditModal' }); - - dr.actions() - .doubleClick(dr.findElement({ css: '.passages .passage' })) - .perform(); - dr.wait(until.elementIsVisible(modal)); - - assert(getCMText(dr, '#passageEditModal')).equalTo(helpers.longUni); - }); - - test.it('Adds passages that are newly linked', function() { - var modal = dr.findElement({ css: '#passageEditModal' }); - - dr.actions() - .doubleClick(dr.findElement({ css: '.passages .passage' })) - .perform(); - dr.wait(until.elementIsVisible(modal)); - setCMText(dr, '#passageEditModal', '[[a new link]]'); - dr.findElement({ css: '#passageEditModal .close' }).click(); - dr.wait(until.elementIsNotVisible(modal)); - - dr.wait(until.elementLocated({ - css: '.passages .passage:nth-of-type(2) .title', - })); - assert( - dr.findElement({ - css: '.passages .passage:nth-of-type(2) .title', - }).getText() - ).equalTo('a new link'); - }); - - test.it('Does not add passages for linked URLs', function() { - var modal = dr.findElement({ css: '#passageEditModal' }); - - dr.actions() - .doubleClick(dr.findElement({ css: '.passages .passage' })) - .perform(); - dr.wait(until.elementIsVisible(modal)); - setCMText(dr, '#passageEditModal', 'http://twinery.org'); - dr.findElement({ css: '#passageEditModal .close' }).click(); - dr.wait(until.elementIsNotVisible(modal)); - - dr.findElements({ css: '.passages .passage' }).then(function(els) { - assert(els.length).equalTo(1); - }); - }); - - test.it('Updates links when passages are renamed', function() { - var modal = dr.findElement({ css: '#passageEditModal' }); - - dr.actions() - .doubleClick(dr.findElement({ css: '.passages .passage' })) - .perform(); - dr.wait(until.elementIsVisible(modal)); - setCMText(dr, '#passageEditModal', '[[linked passage]]'); - dr.findElement({ css: '#passageEditModal .close' }).click(); - dr.wait(until.elementIsNotVisible(modal)); - - dr.navigate().refresh(); - modal = dr.findElement({ css: '#passageEditModal' }); - - dr.actions() - .doubleClick(dr.findElement({ css: '.passages .passage:nth-of-type(2)' })) - .perform(); - - var passageName = dr.findElement({ - css: '#passageEditModal input.passageName', - }); - passageName.clear(); - passageName.sendKeys('2 linked passage'); - dr.findElement({ css: '#passageEditModal .close' }).click(); - dr.wait(until.elementIsNotVisible(modal)); - - assert( - dr.findElement({css: '.passages .passage:first-child .excerpt'}).getText() - ).equalTo('[[2 linked passage]]'); - }); - - test.it('Deletes a passage by clicking its delete button', function() { - var passage = dr.findElement({ css: '.passages .passage' }); - - dr.actions().mouseMove(passage).perform(); - dr.findElement({ css: '.passages .passage .delete' }).click(); - dr.wait(until.elementLocated({ css: '.modal.confirm' })); - dr.findElement({ css: '.modal.confirm button.danger' }).click(); - dr.wait(until.stalenessOf(passage)); - }); - - test.it('Immediately deletes a passage by clicking its delete button with the shift key held', function() {// jscs:ignore maximumLineLength - var passage = dr.findElement({ css: '.passages .passage' }); - - dr.actions().mouseMove(passage).sendKeys(key.SHIFT).perform(); - dr.findElement({ css: '.passages .passage .delete' }).click(); - dr.wait(until.stalenessOf(passage)); - }); - - test.it('Changes zoom levels with the toolbar', function() { - dr.findElement({ css: '.toolbar .zoomSmall' }).click(); - dr.wait(until.elementLocated({ css: '.main .zoom-small' })); - dr.findElement({ css: '.toolbar .zoomMedium' }).click(); - dr.wait(until.elementLocated({ css: '.main .zoom-medium' })); - dr.findElement({ css: '.toolbar .zoomBig' }).click(); - dr.wait(until.elementLocated({ css: '.main .zoom-big' })); - }); - - test.it('Tests a story with the Test button', function() { - dr.findElement({ css: '.toolbar .testStory' }).click(); - dr.getAllWindowHandles(function(winds) { - var found; - - for (var i = 0; i < winds.length; i++) { - if (i == helpers.shortUni) { - found = true; - } - } - - assert(found).isTrue(); - }); - }); - - test.it('Plays a story with the Play button', function() { - dr.findElement({ css: '.toolbar .playStory' }).click(); - dr.getAllWindowHandles(function(winds) { - var found; - - for (var i = 0; i < winds.length; i++) { - if (i == helpers.shortUni) { - found = true; - } - } - - assert(found).isTrue(); - }); - }); - - test.it('Renames a story via a dialog', function() { - var modal = dr.findElement({ css: '#renameStoryModal' }); - - dr.findElement({ css: 'button.storyName' }).click(); - dr.findElement({ css: 'button.renameStory' }).click(); - dr.wait(until.elementIsVisible(modal)); - dr.findElement({ css: '#renameStoryModal input.storyName' }).clear(); - dr - .findElement({ css: '#renameStoryModal input.storyName' }) - .sendKeys('This is different'); - dr.findElement({ css: '#renameStoryModal button[type="submit"]' }).click(); - dr.wait(until.elementIsNotVisible(modal)); - assert(dr.findElement({ css: '.storyNameVal' }).getText()) - .equalTo('This is different'); - }); - - test.it('Creates a proofing version of the story via a menu item', function() { // jscs:ignore maximumLineLength - dr.findElement({ css: 'button.storyName' }).click(); - dr.findElement({ css: 'button.proofStory' }).click(); - dr.getAllWindowHandles(function(winds) { - var found; - - for (var i = 0; i < winds.length; i++) { - if (i == helpers.shortUni) { - found = true; - } - } - - assert(found).isTrue(); - }); - }); - - test.it('Creates a published version of the story via a menu item', function() { // jscs:ignore maximumLineLength - dr.findElement({ css: 'button.storyName' }).click(); - dr.findElement({ css: 'button.publishStory' }).click(); - }); - - test.it('Saves changes to story script', function() { - var modal = dr.findElement({ css: '#scriptEditModal' }); - - dr.findElement({ css: 'button.storyName' }).click(); - dr.findElement({ css: 'button.editScript' }).click(); - dr.wait(until.elementIsVisible(modal)); - setCMText(dr, '#scriptEditModal', helpers.longUni); - - dr.findElement({ css: '#scriptEditModal button.save' }).click(); - dr.wait(until.elementIsNotVisible(modal)); - - dr.navigate().refresh(); - modal = dr.findElement({ css: '#scriptEditModal' }); - - dr.findElement({ css: 'button.storyName' }).click(); - dr.findElement({ css: 'button.editScript' }).click(); - dr.wait(until.elementIsVisible(modal)); - - assert(getCMText(dr, '#scriptEditModal')).equalTo(helpers.longUni); - }); - - test.it('Saves changes to story stylesheet', function() { - var modal = dr.findElement({ css: '#stylesheetEditModal' }); - - dr.findElement({ css: 'button.storyName' }).click(); - dr.findElement({ css: 'button.editStyle' }).click(); - dr.wait(until.elementIsVisible(modal)); - setCMText(dr, '#stylesheetEditModal', helpers.longUni); - - dr.findElement({ css: '#stylesheetEditModal button.save' }).click(); - dr.wait(until.elementIsNotVisible(modal)); - - dr.navigate().refresh(); - modal = dr.findElement({ css: '#stylesheetEditModal' }); - - dr.findElement({ css: 'button.storyName' }).click(); - dr.findElement({ css: 'button.editStyle' }).click(); - dr.wait(until.elementIsVisible(modal)); - - assert(getCMText(dr, '#stylesheetEditModal')).equalTo(helpers.longUni); - }); - - test.it('Disables keyboard shortcuts when editing a passage', function() { - var modal = dr.findElement({ css: '#scriptEditModal' }); - - dr.findElement({ css: 'button.storyName' }).click(); - dr.findElement({ css: 'button.editScript' }).click(); - dr.wait(until.elementIsVisible(modal)); - - dr.findElement({ css: '#scriptEditModal' }).sendKeys(key.DELETE); - dr.findElement({ css: '#scriptEditModal button.save' }).click(); - dr.wait(until.elementIsNotVisible(modal)); - - dr.findElement({ css: '.passages .passage' }); - }); - - test.it('Numbers new passages to avoid name conflicts', function() { - dr.findElement({ css: '.toolbar .addPassage' }).click(); - dr.wait( - until.elementLocated({ css: '.passages .passage:nth-of-type(2) .title' }) - ); - assert( - dr.findElement({ css: '.passages .passage:nth-of-type(2) .title' }) - .getText() - ).equalTo('Untitled Passage 1'); - dr.findElement({ css: '.toolbar .addPassage' }).click(); - dr.wait( - until.elementLocated({ css: '.passages .passage:nth-of-type(3) .title' }) - ); - assert( - dr.findElement({ css: '.passages .passage:nth-of-type(3) .title' }) - .getText() - ).equalTo('Untitled Passage 2'); - }); - - test.it('Warns a user before navigating away while editing a passage', function() {// jscs:ignore maximumLineLength - var modal = dr.findElement({ css: '#passageEditModal' }); - - assert(dr.executeScript('return window.onbeforeunload')).isNull(); - dr.actions() - .doubleClick(dr.findElement({ css: '.passages .passage' })) - .perform(); - dr.wait(until.elementIsVisible(modal)); - assert(dr.executeScript('return window.onbeforeunload')).not.isNull(); - dr.findElement({ css: '#passageEditModal button.close' }).click(); - assert(dr.executeScript('return window.onbeforeunload')).isNull(); - }); - - test.it('Shows accurate story statistics', function() { - var modal = dr.findElement({ css: '#passageEditModal' }); - - assert(dr.executeScript('return window.onbeforeunload')).isNull(); - dr.actions() - .doubleClick(dr.findElement({ css: '.passages .passage' })) - .perform(); - dr.wait(until.elementIsVisible(modal)); - setCMText( - dr, - '#passageEditModal', - '[[red]] [[green]] [[blue]] The quick brown fox jumps over the lazy dog.' - ); - dr.findElement({ css: '#passageEditModal button.save' }).click(); - dr.wait(until.elementIsNotVisible(modal)); - dr.findElement({ css: 'body' }).sendKeys(key.END); - dr.sleep(500); // Accommodate smooth scrolling - - var passage = dr.findElement({ css: '.passages .passage:nth-of-type(2)' }); - dr.actions().mouseMove(passage).sendKeys(key.SHIFT).perform(); - dr.findElement({ - css: '.passages .passage:nth-of-type(2) .delete', - }).click(); - dr.wait(until.stalenessOf(passage)); - - dr.findElement({ css: 'button.storyName' }).click(); - dr.findElement({ css: 'button.storyStats' }).click(); - - modal = dr.findElement({ css: '#statsModal' }); - dr.wait(until.elementIsVisible(modal)); - assert(dr.findElement({ css: 'td.charCount' }).getText()).equalTo('145'); - assert(dr.findElement({ css: 'td.wordCount' }).getText()).equalTo('24'); - assert(dr.findElement({ css: 'td.passageCount' }).getText()).equalTo('3'); - assert(dr.findElement({ css: 'td.linkCount' }).getText()).equalTo('3'); - assert( - dr.findElement({ css: 'td.brokenLinkCount' }).getText() - ).equalTo('1'); - }); - - test.it('Generates IFIDs that meet Treaty of Babel standards', function() { - var modal = dr.findElement({ css: '#statsModal' }); - dr.findElement({ css: 'button.storyName' }).click(); - dr.findElement({ css: 'button.storyStats' }).click(); - dr.wait(until.elementIsVisible(modal)); - - dr.findElement({ css: '.ifid' }).getText(function(ifid) { - assert(ifid.length).greaterThan(7); - assert(ifid.length).lessThan(64); - assert(ifid).not.matches(/[^0-9A-Z\\-]/); - }); - }); - - test.it('Generates IFIDs that are stable', function() { - var modal = dr.findElement({ css: '#statsModal' }); - dr.findElement({ css: 'button.storyName' }).click(); - dr.findElement({ css: 'button.storyStats' }).click(); - dr.wait(until.elementIsVisible(modal)); - - dr.findElement({ css: '.ifid' }).getText(function(firstIfid) { - dr.navigate().refresh(); - - modal = dr.findElement({ css: '#statsModal' }); - dr.findElement({ css: 'button.storyName' }).click(); - dr.findElement({ css: 'button.storyStats' }).click(); - dr.wait(until.elementIsVisible(modal)); - - assert(dr.findElement({ css: '.ifid' }).getText()).equalTo(firstIfid); - }); - }); -}); diff --git a/tests/selenium/story-list.js b/tests/selenium/story-list.js deleted file mode 100644 index 4eff2a084..000000000 --- a/tests/selenium/story-list.js +++ /dev/null @@ -1,146 +0,0 @@ -'use strict'; -var _ = require('underscore'); -var assert = require('selenium-webdriver/testing/assert'); -var firefox = require('selenium-webdriver/firefox'); -var phantomjs = require('selenium-webdriver/phantomjs'); -var test = require('selenium-webdriver/testing'); -var until = require('selenium-webdriver').until; -var helpers = require('./helpers'); - -test.describe('StoryListView', function() { - var dr; - this.timeout(10000); - - test.beforeEach(function() { - dr = new firefox.Driver(); - dr.get(helpers.testUrl + '#stories'); - }); - - test.afterEach(function() { - dr.quit(); - }); - - test.it('Is on the #stories route', function() { - assert(dr.isElementPresent({ css: '#regions #storyListView' })).isTrue(); - }); - - test.it('Shows initial message when no stories are saved', function() { - dr.wait(until.elementLocated({ css: '.noStories' })) - .then(function() { - var noStories = dr.findElement({ css: '.noStories' }); - dr.wait(until.elementIsVisible(noStories)); - }); - }); - - test.it('Can cancel out of adding a new story', function() { - var addButton = dr.findElement({ css: '.addStory' }); - var bubble = addButton - .findElement({ xpath: '../..' }) - .findElement({ css: '.bubble '}); - - addButton.click(); - dr.wait(until.elementIsVisible(bubble)); - dr.findElement({ css: '.cancelAdd' }).click(); - dr.wait(until.elementIsNotVisible(bubble)); - }); - - test.it('Does not allow blank story names', function() { - var addButton = dr.findElement({ css: '.addStory' }); - var bubble = addButton - .findElement({ xpath: '../..' }) - .findElement({ css: '.bubble '}); - - addButton.click(); - dr.wait(until.elementIsVisible(bubble)); - dr.findElement({ css: '.add' }).click(); - assert(bubble.isDisplayed()).isTrue(); - }); - - test.it('Can add a new story', function() { - helpers.createStory(dr, true); - dr.wait(until.elementLocated({ css: '.storyName' })); - assert( - dr.findElement({ css: '.storyName' }).getText() - ).equalTo(helpers.shortUni); - }); - - test.it('Can play a story', function() { - helpers.createStory(dr); - dr.findElement({ css: '.story button[data-bubble="toggle"]' }).click(); - dr.findElement({ css: '.story .menu .play' }).click(); - dr.getAllWindowHandles(function(windows) { - assert(_.contains(windows, helpers.shortUni)).isTrue(); - }); - }); - - test.it('Can test a story', function() { - helpers.createStory(dr); - dr.findElement({ css: '.story button[data-bubble="toggle"]' }).click(); - dr.findElement({ css: '.story .menu .test' }).click(); - dr.getAllWindowHandles(function(windows) { - assert(_.contains(windows, helpers.shortUni)).isTrue(); - }); - }); - - test.it('Can rename a story', function() { - helpers.createStory(dr); - dr.findElement({ css: '.story button[data-bubble="toggle"]' }).click(); - dr.findElement({ css: '.story .menu .rename' }).click(); - dr.wait(until.elementLocated({ css: '.prompt input[type="text"]' })); - - var promptEl = dr.findElement({ css: '.prompt' }); - - dr - .findElement({ css: '.prompt input[type="text"]' }) - .sendKeys('123 ' + helpers.shortUni); - dr - .findElement({ css: '.prompt button[data-action="yes"]' }) - .click(); - - dr.wait(until.elementIsNotVisible(promptEl)); - assert( - dr.findElement({ css: '.story h2' }).getText() - ).equalTo('123 ' + helpers.shortUni); - }); - - test.it('Can cancel out of deleting a story', function() { - helpers.createStory(dr); - dr.findElement({ css: '.story button[data-bubble="toggle"]' }).click(); - dr.findElement({ css: '.story .menu .confirmDelete' }).click(); - dr.wait(until.elementLocated({ css: '.modal.confirm.appear' })); - var cancelButton = dr.findElement({ - css: '.modal.confirm.appear button.cancel', - }); - - dr.wait(until.elementIsVisible(cancelButton)).then(function() { - var confirmEl = dr.findElement({ css: '.modal.confirm.appear' }); - cancelButton.click(); - dr.wait(until.elementIsNotVisible(confirmEl)); - assert(dr.isElementPresent({ css: '.story h2' })).isTrue(); - }); - }); - - test.it('Can delete a story', function() { - helpers.createStory(dr); - dr.findElement({ css: '.story button[data-bubble="toggle"]' }).click(); - dr.findElement({ css: '.story .menu .confirmDelete' }).click(); - dr.wait(until.elementLocated({ css: '.modal.confirm.appear' })); - var deleteButton = dr.findElement({ - css: '.modal.confirm.appear button[data-action="yes"]', - }); - - dr.wait(until.elementIsVisible(deleteButton)).then(function() { - var confirmEl = dr.findElement({ css: '.modal.confirm.appear' }); - deleteButton.click(); - dr.wait(until.elementIsNotVisible(confirmEl)); - - var noStories = dr.findElement({ css: '.noStories' }); - dr.wait(until.elementIsVisible(noStories)); - }); - }); - - test.it('Can generate an archive', function() { - helpers.createStory(dr); - dr.findElement({ css: '.saveArchive' }).click(); - }); -}); diff --git a/tests/selenium/welcome.js b/tests/selenium/welcome.js deleted file mode 100644 index 3fac4e758..000000000 --- a/tests/selenium/welcome.js +++ /dev/null @@ -1,50 +0,0 @@ -'use strict'; -var assert = require('selenium-webdriver/testing/assert'); -var firefox = require('selenium-webdriver/firefox'); -var test = require('selenium-webdriver/testing'); -var until = require('selenium-webdriver').until; - -test.describe('WelcomeView', function() { - var dr; - var testUrl = 'file://' + - __dirname.replace('/tests/selenium', '') + - '/build/standalone/index.html'; - this.timeout(15000); - - test.beforeEach(function() { - dr = new firefox.Driver(); - dr.get(testUrl + '#welcome'); - }); - - test.afterEach(function() { - dr.quit(); - }); - - test.it('Is on the #welcome route', function() { - assert(dr.isElementPresent({ css: 'div.hi h1' })); - }); - - test.it('Can be walked through', function() { - var helpDiv = dr.findElement({ css: 'div.help' }); - var saveDiv = dr.findElement({ css: 'div.save' }); - var thanksDiv = dr.findElement({ css: 'div.thanks' }); - - dr.findElement({ css: 'div.hi button' }).click(); - dr.wait(until.elementIsVisible(helpDiv)); - assert(dr.findElement({ css: 'div.help h1' }).getText()) - .equalTo('New here?'); - - dr.findElement({ css: 'div.help button' }).click(); - dr.wait(until.elementIsVisible(saveDiv)); - assert(dr.findElement({ css: 'div.save h1' }).getText()) - .equalTo('Your work is saved only in your browser.'); - - dr.findElement({ css: 'div.save button' }).click(); - dr.wait(until.elementIsVisible(thanksDiv)); - assert(dr.findElement({ css: 'div.thanks h1' }).getText()) - .equalTo('That\'s it!'); - - dr.findElement({ css: 'div.thanks button' }).click(); - dr.wait(until.elementLocated({ css: '#storyListView h1' })); - }); -}); diff --git a/tests/unit/passage.js b/tests/unit/passage.js deleted file mode 100644 index ac08a2eb8..000000000 --- a/tests/unit/passage.js +++ /dev/null @@ -1,196 +0,0 @@ -'use strict'; -var Passage = require('../../src/data/models/passage'); -var Story = require('../../src/data/models/passage'); -var assert = require('assert'); - -describe('Passage', function() -{ - var story; - var p; - - beforeEach(function() - { - p = new Passage(); - }); - - describe('excerpt()', function() { - it('returns the passage text if it has under 101 characters', function() { - var text = Array(101)+''; - p.set('text', text); - assert.equal(p.excerpt(), text); - }); - it('HTML-escapes returned passage text', function() { - var text = ""; - p.set('text', text); - assert.equal(p.excerpt(), "<b>"); - }); - it('returns 99 characters of passage text, plus …, if it has 101 or more characters', function() { - var text = Array(102)+''; - p.set('text', text); - assert.equal(p.excerpt(), text.slice(0,99) + "…"); - }) - }); - - describe('publish()', function() { - it('outputs HTML for the passage', function() { - function expected(pid, name, tags, top, left, text) { - return '' + text + '\n'; - } - var props = { - name: "Foo", - tags: ['bar', 'baz', 'qux'], - top: 0, - left: 0, - text: "grault garply corge" - }; - p.set(props); - assert.equal(p.publish(5), expected(5, props.name, props.tags, props.top, props.left, props.text)); - - props = { - name: "", - tags: ['>','"'], - top: 15385, - left: 22408, - text: "" - }; - p.set(props); - assert.equal(p.publish(8), expected(8, props.name, [">","""], props.top, props.left, - "</tw-passagedata>")); - }); - }); - - describe('matches()', function() { - beforeEach(function() { - p.set('name', "foo bar baz qux"); - p.set('text', 'garply') - }); - it('returns true if the passage name matches the passed-in RegExp', function() { - assert(p.matches(/r\s+b/)); - assert(!p.matches(/w/)); - }); - it('returns true if the passage text, unescaped, matches the passed-in RegExp', function() { - assert(p.matches(/rp/)); - assert(p.matches(//)); - assert(!p.matches(/w/)); - }); - }); - - describe('links()', function() - { - - it('parses [[simple links]]', function() - { - p.set('text', '[[link]]'); - var links = p.links(); - assert.equal(links.length,1); - assert.equal(links[0], 'link'); - }); - - it('parses [[pipe|links]]', function() - { - p.set('text', '[[display|link]]'); - var links = p.links(); - assert.equal(links.length, 1); - assert.equal(links[0], 'link'); - }); - - it('parses [[arrow->links]]', function() - { - p.set('text', '[[display->link]]'); - var links = p.links(); - assert.equal(links.length, 1); - assert.equal(links[0], 'link'); - }); - - it('parses [[backarrow<-links]]', function() - { - p.set('text', '[[link<-display]]'); - var links = p.links(); - assert.equal(links.length, 1); - assert.equal(links[0],'link'); - }); - - it('parses [[simple links][setter]] while ignoring setter component', function() - { - p.set('text', '[[link][setter]]'); - var links = p.links(); - assert.equal(links.length, 1); - assert.equal(links[0],'link'); - }); - - it('parses [[pipe|links][setter]] while ignoring setter component', function() - { - p.set('text', '[[display|link][setter]]'); - var links = p.links(); - assert.equal(links.length, 1); - assert.equal(links[0], 'link'); - }); - - it('parses [[arrow->links][setter]] while ignoring setter component', function() - { - p.set('text', '[[display->link][setter]]'); - var links = p.links(); - assert.equal(links.length, 1); - assert.equal(links[0], 'link'); - }); - - it('parses [[backarrow<-links][setter]] while ignoring setter component', function() - { - p.set('text', '[[link<-display][setter]]'); - var links = p.links(); - assert.equal(links.length, 1); - assert.equal(links[0], 'link'); - }); - - it('parses [[simple links][]] while ignoring empty setter component', function() - { - p.set('text', '[[link][]]'); - var links = p.links(); - assert.equal(links.length, 1); - assert.equal(links[0], 'link'); - }); - - it('parses [[pipe|links][]] while ignoring empty setter component', function() - { - p.set('text', '[[display|link][]]'); - var links = p.links(); - assert.equal(links.length, 1); - assert.equal(links[0], 'link'); - }); - - it('parses [[arrow->links][]] while ignoring empty setter component', function() - { - p.set('text', '[[display->link][]]'); - var links = p.links(); - assert.equal(links.length, 1); - assert.equal(links[0], 'link'); - }); - - it('parses [[backarrow<-links][]] while ignoring empty setter component', function() - { - p.set('text', '[[link<-display][]]'); - var links = p.links(); - assert.equal(links.length, 1); - assert.equal(links[0], 'link'); - }); - - it('ignores [[]]', function() - { - p.set('text', '[[]]'); - assert.equal(p.links().length, 0); - }); - - it('ignores [[][]]', function() - { - p.set('text', '[[][]]'); - assert.equal(p.links().length, 0); - }); - - it('ignores [[][setter]]', function() - { - p.set('text', '[[][setter]]'); - assert.equal(p.links().length, 0); - }); - }); -});