├── .gitignore ├── .jshintrc ├── .travis.yml ├── README.md ├── amd.boot.js ├── basics ├── error.js ├── event_emitter.js ├── factory.js ├── helpers.js ├── index.js ├── oo.js ├── path_adapter.js ├── registry.js ├── tic.js └── uuid.js ├── browser.js ├── data ├── data.js ├── incremental_data.js ├── index.js ├── node.js ├── node_factory.js ├── node_index.js └── schema.js ├── document ├── abstract_document.js ├── anchor_index.js ├── annotation.js ├── annotation_index.js ├── annotation_updates.js ├── annotator.js ├── clipboard_exporter.js ├── clipboard_importer.js ├── container.js ├── container_annotation.js ├── container_annotation_index.js ├── container_selection.js ├── coordinate.js ├── document.js ├── document_change.js ├── document_schema.js ├── html_exporter.js ├── html_importer.js ├── index.js ├── node.js ├── nodes │ ├── emphasis.js │ ├── heading.js │ ├── include.js │ ├── link.js │ ├── list.js │ ├── list_item.js │ ├── paragraph.js │ ├── strong.js │ ├── table.js │ ├── table_cell.js │ ├── table_matrix.js │ ├── table_row.js │ └── table_section.js ├── path_event_proxy.js ├── property_selection.js ├── range.js ├── selection.js ├── table_selection.js ├── text_node.js ├── transaction_document.js └── transformations │ ├── break_node.js │ ├── copy_selection.js │ ├── delete_character.js │ ├── delete_node.js │ ├── delete_selection.js │ ├── index.js │ ├── insert_node.js │ ├── insert_text.js │ ├── merge.js │ ├── paste.js │ └── switch_text_type.js ├── gulpfile.js ├── helpers.js ├── index.js ├── karma.conf.js ├── operator ├── array_operation.js ├── conflict.js ├── index.js ├── object_operation.js ├── operation.js └── text_operation.js ├── package.json ├── server.js ├── surface ├── annotation_tool.js ├── annotation_view.js ├── clipboard.js ├── container_editor.js ├── form_editor.js ├── index.js ├── node_view.js ├── panel.js ├── surface.js ├── surface_manager.js ├── surface_selection.js ├── switch_type_tool.js ├── text_property.js ├── tool.js ├── tool_registry.js └── tools │ ├── delete_columns.js │ ├── delete_rows.js │ ├── emphasis_tool.js │ ├── index.js │ ├── insert_columns.js │ ├── insert_rows.js │ ├── link_tool.js │ ├── redo_tool.js │ ├── strong_tool.js │ ├── switch_text_type_tool.js │ └── undo_tool.js ├── test ├── fixtures │ ├── container_anno_sample.js │ ├── sample1.html │ └── sample1.js ├── index.html ├── lib │ ├── jquery.js │ ├── qunit.css │ └── qunit.js ├── test_article │ ├── index.js │ ├── test_article.js │ ├── test_article_meta.js │ ├── test_container_annotation.js │ ├── test_html_importer.js │ ├── test_node.js │ └── test_schema.js └── unit │ ├── document │ ├── container_annotation_index.test.js │ ├── container_selection.test.js │ ├── document.transaction.test.js │ ├── path_eventproxy.test.js │ └── transformations │ │ ├── break_node.test.js │ │ ├── copy_selection.test.js │ │ ├── delete_character.test.js │ │ ├── delete_selection.test.js │ │ ├── insert_text.test.js │ │ └── merge.test.js │ ├── load.js │ ├── operator │ ├── array_operation.test.js │ ├── object_operation.test.js │ └── text_operation.test.js │ ├── qunit_extensions.js │ └── surface │ └── surface_selection.test.js └── ui ├── component.js ├── dropdown_component.js ├── font_awesome_icon.js ├── html-editor ├── default_toolbar.js ├── html_article.js ├── html_editor.js └── index.js ├── nodes ├── annotation_component.js ├── container_node_component.js ├── figure_component.js ├── heading_component.js ├── image_component.js ├── include_component.js ├── link_component.js ├── list_component.js ├── paragraph_component.js ├── table_component.js └── unsupported_node.js ├── styles ├── _base.scss ├── _clipboard.scss ├── _colors.scss ├── _helpers.scss ├── _layout.scss ├── _mixins.scss ├── _reset.scss ├── _variables.scss ├── index.scss ├── modules │ ├── annotation_tool.scss │ ├── heading.scss │ ├── link.scss │ ├── list.scss │ ├── paragraph.scss │ └── text_tool.scss └── writer.scss ├── text_property_component.js ├── tools ├── link_tool_component.js ├── openmodal_tool_component.js ├── table_tool_component.js ├── text_tool_component.js └── tool_component.js └── writer ├── content_panel.js ├── context_toggles.js ├── extension_manager.js ├── index.js ├── modal_panel.js ├── panel.js ├── scrollbar.js ├── status_bar.js ├── toc_panel.js └── writer.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | 30 | dist 31 | doc 32 | test/tmp 33 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esnext": true, 3 | "node": true, 4 | "jquery": true, 5 | "devel": true, 6 | "latedef": true, 7 | "undef": true, 8 | "unused": true, 9 | "sub": true, 10 | "predef": [ 11 | "window", 12 | "document", 13 | "QUnit" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.10.38" -------------------------------------------------------------------------------- /amd.boot.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var Substance = require('./index'); 3 | /* global define, Ember */ 4 | define('substance', [], function() { return Substance; }); 5 | })(); 6 | -------------------------------------------------------------------------------- /basics/error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var OO = require('./oo'); 4 | 5 | /** 6 | * Base class for Substance errors. 7 | * 8 | * @class SubstanceError 9 | * @extends Error 10 | * @constructor 11 | * @module Basics 12 | */ 13 | function SubstanceError() { 14 | Error.apply(this, arguments); 15 | } 16 | 17 | OO.inherit(SubstanceError, Error); 18 | 19 | module.exports = SubstanceError; 20 | -------------------------------------------------------------------------------- /basics/factory.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var OO = require('./oo'); 4 | var Registry = require('./registry'); 5 | 6 | /** 7 | * Factory 8 | * ------- 9 | * Simple factory implementation. 10 | * 11 | * @class Factory 12 | * @extends Registry 13 | * @constructor 14 | * @module Basics 15 | */ 16 | function Factory() { 17 | Factory.super.call(this); 18 | } 19 | 20 | Factory.Prototype = function() { 21 | 22 | /** 23 | * Create an instance of the clazz with a given name. 24 | * 25 | * @param {String} name 26 | * @return A new instance. 27 | * @method create 28 | */ 29 | this.create = function ( name ) { 30 | var clazz = this.get(name); 31 | if ( !clazz ) { 32 | throw new Error( 'No class registered by that name: ' + name ); 33 | } 34 | // call the clazz providing the remaining arguments 35 | var args = Array.prototype.slice.call( arguments, 1 ); 36 | var obj = Object.create( clazz.prototype ); 37 | clazz.apply( obj, args ); 38 | return obj; 39 | }; 40 | 41 | }; 42 | 43 | OO.inherit(Factory, Registry); 44 | 45 | module.exports = Factory; 46 | -------------------------------------------------------------------------------- /basics/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('./helpers'); 4 | 5 | /** 6 | * Substance.Basics 7 | * ---------------- 8 | * A collection of helpers pulled together from different sources, such as lodash. 9 | * 10 | * @module Basics 11 | * @main Basics 12 | */ 13 | var Basics = {}; 14 | 15 | _.extend(Basics, require('./helpers')); 16 | _.extend(Basics, require('./oo')); 17 | Basics.OO = require('./oo'); 18 | Basics.PathAdapter = require('./path_adapter'); 19 | Basics.EventEmitter = require('./event_emitter'); 20 | Basics.Error = require('./error'); 21 | Basics.Registry = require('./registry'); 22 | Basics.Factory = require('./factory'); 23 | _.extend(Basics, require('./tic')); 24 | 25 | module.exports = Basics; 26 | -------------------------------------------------------------------------------- /basics/oo.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('./helpers'); 4 | 5 | /** 6 | * Helpers for OO programming. 7 | * 8 | * Inspired by VisualEditor's OO module. 9 | * 10 | * @class OO 11 | * @static 12 | * @module Basics 13 | */ 14 | var OO = {}; 15 | 16 | var extend = function( parent, proto ) { 17 | var ctor = function $$$() { 18 | parent.apply(this, arguments); 19 | if (this.init) { 20 | this.init.apply(this, arguments); 21 | } 22 | }; 23 | OO.inherit(ctor, parent); 24 | for(var key in proto) { 25 | if (proto.hasOwnProperty(key)) { 26 | if (key === "name") { 27 | continue; 28 | } 29 | ctor.prototype[key] = proto[key]; 30 | } 31 | } 32 | ctor.static.name = proto.name; 33 | return ctor; 34 | }; 35 | 36 | /** 37 | * Initialize a class. 38 | * 39 | * @param {Constructor} clazz 40 | * @method initClass 41 | */ 42 | OO.initClass = function(clazz) { 43 | if (clazz.Prototype && !(clazz.prototype instanceof clazz.Prototype)) { 44 | clazz.prototype = new clazz.Prototype(); 45 | clazz.prototype.constructor = clazz; 46 | } 47 | clazz.static = clazz.static || {}; 48 | clazz.extend = clazz.extend || _.bind(extend, null, clazz); 49 | }; 50 | 51 | /** 52 | * Inherit from a parent class. 53 | * 54 | * @param clazz {Constructor} class constructor 55 | * @param parentClazz {Constructor} parent constructor 56 | * 57 | * @method inherit 58 | */ 59 | OO.inherit = function(clazz, parentClazz) { 60 | if (clazz.prototype instanceof parentClazz) { 61 | throw new Error('Target already inherits from origin'); 62 | } 63 | var targetConstructor = clazz.prototype.constructor; 64 | // Customization: supporting a prototype constructor function 65 | // defined as a static member 'Prototype' of the target function. 66 | var TargetPrototypeCtor = clazz.Prototype; 67 | // Provide a shortcut to the parent constructor 68 | clazz.super = parentClazz; 69 | if (TargetPrototypeCtor) { 70 | TargetPrototypeCtor.prototype = parentClazz.prototype; 71 | clazz.prototype = new TargetPrototypeCtor(); 72 | clazz.prototype.constructor = clazz; 73 | } else { 74 | clazz.prototype = Object.create(parentClazz.prototype, { 75 | // Restore constructor property of clazz 76 | constructor: { 77 | value: targetConstructor, 78 | enumerable: false, 79 | writable: true, 80 | configurable: true 81 | } 82 | }); 83 | } 84 | // provide a shortcut to the parent prototype 85 | clazz.prototype.super = parentClazz.prototype; 86 | // Extend static properties - always initialize both sides 87 | OO.initClass( parentClazz ); 88 | clazz.static = Object.create(parentClazz.static); 89 | clazz.extend = _.bind(extend, null, clazz); 90 | }; 91 | 92 | /** 93 | * @param clazz {Constructor} class constructor 94 | * @param mixinClazz {Constructor} parent constructor 95 | * @method mixin 96 | */ 97 | OO.mixin = function(clazz, mixinClazz) { 98 | var key; 99 | var prototype = mixinClazz.prototype; 100 | if (mixinClazz.Prototype) { 101 | prototype = new mixinClazz.Prototype(); 102 | } 103 | // Copy prototype properties 104 | for ( key in prototype ) { 105 | if ( key !== 'constructor' && prototype.hasOwnProperty( key ) ) { 106 | clazz.prototype[key] = prototype[key]; 107 | } 108 | } 109 | // make sure the clazz is initialized 110 | OO.initClass(clazz); 111 | // Copy static properties 112 | if ( mixinClazz.static ) { 113 | for ( key in mixinClazz.static ) { 114 | if ( mixinClazz.static.hasOwnProperty( key ) ) { 115 | clazz.static[key] = mixinClazz.static[key]; 116 | } 117 | } 118 | } else { 119 | OO.initClass(mixinClazz); 120 | } 121 | }; 122 | 123 | module.exports = OO; 124 | -------------------------------------------------------------------------------- /basics/registry.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var oo = require('./oo'); 4 | 5 | /** 6 | * Simple registry implementation. 7 | * 8 | * @class Registry 9 | * @constructor 10 | * @module Basics 11 | */ 12 | function Registry() { 13 | this.entries = {}; 14 | // used to control order 15 | this.names = []; 16 | } 17 | 18 | Registry.Prototype = function() { 19 | 20 | /** 21 | * Check if an entry is registered for a given name. 22 | * 23 | * @param {String} name 24 | * @method contains 25 | */ 26 | this.contains = function(name) { 27 | return !!this.entries[name]; 28 | }; 29 | 30 | /** 31 | * Add an entry to the registry. 32 | * 33 | * @param {String} name 34 | * @param {Object} entry 35 | * @method add 36 | */ 37 | this.add = function(name, entry) { 38 | if (this.contains(name)) { 39 | this.remove(name); 40 | } 41 | this.entries[name] = entry; 42 | this.names.push(name); 43 | }; 44 | 45 | /** 46 | * Remove an entry from the registry. 47 | * 48 | * @param {String} name 49 | * @method remove 50 | */ 51 | this.remove = function(name) { 52 | var pos = this.names.indexOf(name); 53 | if (pos >= 0) { 54 | this.names.splice(pos, 1); 55 | } 56 | delete this.entries[name]; 57 | }; 58 | 59 | this.clear = function() { 60 | this.names = []; 61 | this.entries = []; 62 | }; 63 | 64 | /** 65 | * Get the entry registered for a given name. 66 | * 67 | * @param {String} name 68 | * @return The registered entry 69 | * @method get 70 | */ 71 | this.get = function(name) { 72 | var res = this.entries[name]; 73 | 74 | if (!res) { 75 | console.error("No entry with name", name); 76 | } 77 | return res; 78 | }; 79 | 80 | /** 81 | * Iterate all registered entries in the order they were registered. 82 | * 83 | * @param {Function} callback with signature function(entry, name) 84 | * @param {Object} execution context 85 | * @method each 86 | */ 87 | this.each = function(callback, ctx) { 88 | for (var i = 0; i < this.names.length; i++) { 89 | var name = this.names[i]; 90 | var _continue = callback.call(ctx, this.entries[name], name); 91 | if (_continue === false) { 92 | break; 93 | } 94 | } 95 | }; 96 | }; 97 | 98 | oo.initClass(Registry); 99 | 100 | module.exports = Registry; 101 | -------------------------------------------------------------------------------- /basics/tic.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var lastTime = Date.now(); 3 | 4 | module.exports = { 5 | tic: function() { 6 | lastTime = Date.now(); 7 | }, 8 | toc: function () { 9 | return Date.now() - lastTime; 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /basics/uuid.js: -------------------------------------------------------------------------------- 1 | /*! 2 | Math.uuid.js (v1.4) 3 | http://www.broofa.com 4 | mailto:robert@broofa.com 5 | Copyright (c) 2010 Robert Kieffer 6 | Dual licensed under the MIT and GPL licenses. 7 | */ 8 | 9 | /** 10 | * Generates a unique id. 11 | * 12 | * @method uuid 13 | * @param {String} [prefix] if provided the UUID will be prefixed. 14 | * @param {Number} [len] if provided a UUID with given length will be created. 15 | * @return A generated uuid. 16 | */ 17 | module.exports = function uuid(prefix, len) { 18 | if (prefix && prefix[prefix.length-1] !== "_") { 19 | prefix = prefix.concat("_"); 20 | } 21 | var chars = '0123456789abcdefghijklmnopqrstuvwxyz'.split(''), 22 | uuid = [], 23 | radix = 16, 24 | idx; 25 | len = len || 32; 26 | if (len) { 27 | // Compact form 28 | for (idx = 0; idx < len; idx++) uuid[idx] = chars[0 | Math.random()*radix]; 29 | } else { 30 | // rfc4122, version 4 form 31 | var r; 32 | // rfc4122 requires these characters 33 | uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-'; 34 | uuid[14] = '4'; 35 | // Fill in random data. At i==19 set the high bits of clock sequence as 36 | // per rfc4122, sec. 4.1.5 37 | for (idx = 0; idx < 36; idx++) { 38 | if (!uuid[idx]) { 39 | r = 0 | Math.random()*16; 40 | uuid[idx] = chars[(idx == 19) ? (r & 0x3) | 0x8 : r]; 41 | } 42 | } 43 | } 44 | return (prefix ? prefix : "") + uuid.join(''); 45 | }; 46 | -------------------------------------------------------------------------------- /browser.js: -------------------------------------------------------------------------------- 1 | var Substance = require('./index'); 2 | window.Substance = Substance; 3 | -------------------------------------------------------------------------------- /data/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Substance.Data 5 | * -------------- 6 | * Provides a data model with a simple CRUD style manuipulation API, 7 | * support for OT based incremental manipulations, etc. 8 | * 9 | * @module Data 10 | * @main Data 11 | */ 12 | 13 | var Data = require('./data'); 14 | 15 | Data.Incremental = require('./incremental_data'); 16 | Data.Node = require('./node'); 17 | Data.Schema = require('./schema'); 18 | Data.Index = require('./node_index'); 19 | 20 | module.exports = Data; 21 | -------------------------------------------------------------------------------- /data/node_factory.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Substance = require('../basics'); 4 | var Node = require('./node'); 5 | var Factory = Substance.Factory; 6 | 7 | /** 8 | * Factory for Nodes. 9 | * 10 | * @class Data.NodeFactory 11 | * @extends Factory 12 | * @constructor 13 | * @module Data 14 | */ 15 | function NodeFactory() { 16 | Factory.call(this); 17 | } 18 | 19 | NodeFactory.Prototype = function() { 20 | /** 21 | * Register a Node class. 22 | * 23 | * @method register 24 | * @param {Class} nodeClass 25 | */ 26 | this.register = function ( nodeClazz ) { 27 | var name = nodeClazz.static && nodeClazz.static.name; 28 | if ( typeof name !== 'string' || name === '' ) { 29 | throw new Error( 'Node names must be strings and must not be empty' ); 30 | } 31 | if ( !( nodeClazz.prototype instanceof Node) ) { 32 | throw new Error( 'Nodes must be subclasses of Substance.Data.Node' ); 33 | } 34 | 35 | if (this.contains(name)) { 36 | throw new Error('Node class is already registered: ' + name); 37 | } 38 | 39 | this.add(name, nodeClazz); 40 | }; 41 | }; 42 | 43 | Substance.inherit(NodeFactory, Factory); 44 | 45 | module.exports = NodeFactory; 46 | -------------------------------------------------------------------------------- /document/anchor_index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Substance = require('../basics'); 4 | var PathAdapter = Substance.PathAdapter; 5 | var Data = require('../data'); 6 | var ContainerAnnotation = require('./container_annotation'); 7 | 8 | var ContainerAnnotationAnchorIndex = function(doc) { 9 | this.doc = doc; 10 | this.byPath = new PathAdapter.Arrays(); 11 | this.byId = {}; 12 | }; 13 | 14 | ContainerAnnotationAnchorIndex.Prototype = function() { 15 | 16 | this.select = function(node) { 17 | return (node instanceof ContainerAnnotation); 18 | }; 19 | 20 | this.reset = function(data) { 21 | this.byPath.clear(); 22 | this.byId = {}; 23 | this._initialize(data); 24 | }; 25 | 26 | this.get = function(path, containerName) { 27 | var anchors = this.byPath.get(path) || []; 28 | if (!Substance.isArray(anchors)) { 29 | var _anchors = []; 30 | this.byPath._traverse(anchors, [], function(path, anchors) { 31 | _anchors = _anchors.concat(anchors); 32 | }); 33 | anchors = _anchors; 34 | } 35 | if (containerName) { 36 | return Substance.filter(anchors, function(anchor) { 37 | return (anchor.container === containerName); 38 | }); 39 | } else { 40 | // return a copy of the array 41 | return anchors.slice(0); 42 | } 43 | return anchors; 44 | }; 45 | 46 | this.create = function(containerAnno) { 47 | var startAnchor = containerAnno.getStartAnchor(); 48 | var endAnchor = containerAnno.getEndAnchor(); 49 | this.byPath.add(startAnchor.path, startAnchor); 50 | this.byPath.add(endAnchor.path, endAnchor); 51 | this.byId[containerAnno.id] = containerAnno; 52 | }; 53 | 54 | this.delete = function(containerAnno) { 55 | var startAnchor = containerAnno.getStartAnchor(); 56 | var endAnchor = containerAnno.getEndAnchor(); 57 | this.byPath.remove(startAnchor.path, startAnchor); 58 | this.byPath.remove(endAnchor.path, endAnchor); 59 | delete this.byId[containerAnno.id]; 60 | }; 61 | 62 | this.update = function(node, path, newValue, oldValue) { 63 | if (this.select(node)) { 64 | var anchor = null; 65 | if (path[1] === 'startPath') { 66 | anchor = node.getStartAnchor(); 67 | } else if (path[1] === 'endPath') { 68 | anchor = node.getEndAnchor(); 69 | } else { 70 | return; 71 | } 72 | this.byPath.remove(oldValue, anchor); 73 | this.byPath.add(anchor.path, anchor); 74 | } 75 | }; 76 | 77 | }; 78 | 79 | Substance.inherit(ContainerAnnotationAnchorIndex, Data.Index); 80 | 81 | module.exports = ContainerAnnotationAnchorIndex; 82 | -------------------------------------------------------------------------------- /document/annotation.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Substance = require('../basics'); 4 | var Node = require('./node'); 5 | 6 | // Annotation 7 | // -------- 8 | // 9 | // An annotation can be used to overlay text and give it a special meaning. 10 | // Annotations only work on text properties. If you want to annotate multiple 11 | // nodes you have to use a ContainerAnnotation. 12 | // 13 | // Properties: 14 | // - path: Identifies a text property in the document (e.g. ["text_1", "content"]) 15 | // - startOffset: the character where the annoation starts 16 | // - endOffset: the character where the annoation starts 17 | 18 | // TODO: in current terminology this is a PropertyAnnotation 19 | var Annotation = Node.extend({ 20 | name: "annotation", 21 | 22 | properties: { 23 | path: ['array', 'string'], 24 | startOffset: 'number', 25 | endOffset: 'number' 26 | }, 27 | 28 | canSplit: function() { 29 | return true; 30 | }, 31 | 32 | getSelection: function() { 33 | return this.getDocument().createSelection({ 34 | type: 'property', 35 | path: this.path, 36 | startOffset: this.startOffset, 37 | endOffset: this.endOffset 38 | }); 39 | }, 40 | 41 | updateRange: function(tx, sel) { 42 | if (!sel.isPropertySelection()) { 43 | throw new Error('Cannot change to ContainerAnnotation.'); 44 | } 45 | if (!Substance.isEqual(this.startPath, sel.start.path)) { 46 | tx.set([this.id, 'path'], sel.start.path); 47 | } 48 | if (this.startOffset !== sel.start.offset) { 49 | tx.set([this.id, 'startOffset'], sel.start.offset); 50 | } 51 | if (this.endOffset !== sel.end.offset) { 52 | tx.set([this.id, 'endOffset'], sel.end.offset); 53 | } 54 | }, 55 | 56 | getText: function() { 57 | var doc = this.getDocument(); 58 | if (!doc) { 59 | console.warn('Trying to use an Annotation which is not attached to the document.'); 60 | return ""; 61 | } 62 | var text = doc.get(this.path); 63 | return text.substring(this.startOffset, this.endOffset); 64 | }, 65 | 66 | // volatile property necessary to render highlighted annotations differently 67 | setActive: function(val) { 68 | if (this.active !== val) { 69 | this.active = val; 70 | this.emit('active', val); 71 | } 72 | }, 73 | 74 | }); 75 | 76 | Annotation.static.isInline = true; 77 | 78 | // default implementation for inline elements 79 | // Attention: there is a difference between the implementation 80 | // of toHtml for annotations and general nodes. 81 | // Annotations are modeled as overlays, so they do not 'own' their content. 82 | // Thus, during conversion HtmlExporter serves the content as a prepared 83 | // array of children element which just need to be wrapped (or can be manipulated). 84 | Annotation.static.toHtml = function(anno, converter, children) { 85 | var id = anno.id; 86 | var tagName = anno.constructor.static.tagName || 'span'; 87 | var $el = $('<' + tagName + '>') 88 | .attr('id', id) 89 | .append(children); 90 | return $el; 91 | }; 92 | 93 | Object.defineProperties(Annotation.prototype, { 94 | startPath: { 95 | get: function() { 96 | return this.path; 97 | } 98 | }, 99 | endPath: { 100 | get: function() { 101 | return this.path; 102 | } 103 | } 104 | }); 105 | 106 | module.exports = Annotation; 107 | -------------------------------------------------------------------------------- /document/annotation_index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Substance = require('../basics'); 4 | var PathAdapter = Substance.PathAdapter; 5 | var Data = require('../data'); 6 | var Annotation = require('./annotation'); 7 | 8 | // Annotation Index 9 | // ---------------- 10 | // 11 | // Lets us look up existing annotations by path and type 12 | // 13 | // To get all annotations for the content of a text node 14 | // 15 | // var aIndex = doc.annotationIndex; 16 | // aIndex.get(["text_1", "content"]); 17 | // 18 | // You can also scope for a specific range 19 | // 20 | // aIndex.get(["text_1", "content"], 23, 45); 21 | 22 | var AnnotationIndex = function() { 23 | this.byPath = new PathAdapter(); 24 | this.byType = new PathAdapter(); 25 | }; 26 | 27 | AnnotationIndex.Prototype = function() { 28 | 29 | this.property = "path"; 30 | 31 | this.select = function(node) { 32 | return (node instanceof Annotation); 33 | }; 34 | 35 | this.reset = function(data) { 36 | this.byPath.clear(); 37 | this.byType.clear(); 38 | this._initialize(data); 39 | }; 40 | 41 | // TODO: use object interface? so we can combine filters (path and type) 42 | this.get = function(path, start, end, type) { 43 | var annotations = this.byPath.get(path) || {}; 44 | if (Substance.isString(path) || path.length === 1) { 45 | // flatten annotations if this is called via node id 46 | var _annos = annotations; 47 | annotations = []; 48 | Substance.each(_annos, function(level) { 49 | annotations = annotations.concat(Substance.map(level, function(anno) { 50 | return anno; 51 | })); 52 | }); 53 | } else { 54 | annotations = Substance.map(annotations, function(anno) { 55 | return anno; 56 | }); 57 | } 58 | /* jshint eqnull:true */ 59 | // null check for null or undefined 60 | if (start != null) { 61 | annotations = Substance.filter(annotations, AnnotationIndex.filterByRange(start, end)); 62 | } 63 | if (type) { 64 | annotations = Substance.filter(annotations, AnnotationIndex.filterByType(type)); 65 | } 66 | return annotations; 67 | }; 68 | 69 | this.create = function(anno) { 70 | this.byType.set([anno.type, anno.id], anno); 71 | this.byPath.set(anno.path.concat([anno.id]), anno); 72 | }; 73 | 74 | this.delete = function(anno) { 75 | this.byType.delete([anno.type, anno.id]); 76 | this.byPath.delete(anno.path.concat([anno.id])); 77 | }; 78 | 79 | this.update = function(node, path, newValue, oldValue) { 80 | if (this.select(node) && path[1] === this.property) { 81 | this.delete({ id: node.id, type: node.type, path: oldValue }); 82 | this.create(node); 83 | } 84 | }; 85 | 86 | }; 87 | 88 | Substance.inherit(AnnotationIndex, Data.Index); 89 | 90 | AnnotationIndex.filterByRange = function(start, end) { 91 | return function(anno) { 92 | var aStart = anno.startOffset; 93 | var aEnd = anno.endOffset; 94 | var overlap = (aEnd >= start); 95 | // Note: it is allowed to omit the end part 96 | /* jshint eqnull: true */ 97 | if (end != null) { 98 | overlap = overlap && (aStart <= end); 99 | } 100 | /* jshint eqnull: false */ 101 | return overlap; 102 | }; 103 | }; 104 | 105 | AnnotationIndex.filterByType = function(type) { 106 | return function(anno) { 107 | return anno.isInstanceOf(type); 108 | }; 109 | }; 110 | 111 | module.exports = AnnotationIndex; -------------------------------------------------------------------------------- /document/clipboard_exporter.js: -------------------------------------------------------------------------------- 1 | var OO = require('../basics/oo'); 2 | var ClipboardImporter = require('./clipboard_importer'); 3 | var HtmlExporter = require('./html_exporter'); 4 | 5 | function ClipboardExporter() { 6 | ClipboardExporter.super.call(this); 7 | } 8 | 9 | ClipboardExporter.Prototype = function() { 10 | 11 | 12 | this.convert = function(doc, options) { 13 | this.initialize(doc, options); 14 | var $doc = this.createHtmlDocument(); 15 | // Note: the content of a clipboard document 16 | // is coming as container with id 'clipboard' 17 | var content = doc.get('clipboard_content'); 18 | $doc.find('body').append(this.convertContainer(content)); 19 | 20 | // This is not working with jquery 21 | //return $doc.html(); 22 | 23 | return $doc.find('html').html(); 24 | }; 25 | 26 | }; 27 | 28 | OO.inherit(ClipboardExporter, HtmlExporter); 29 | 30 | module.exports = ClipboardExporter; 31 | -------------------------------------------------------------------------------- /document/clipboard_importer.js: -------------------------------------------------------------------------------- 1 | var _ = require('../basics/helpers'); 2 | var OO = require('../basics/oo'); 3 | var HtmlImporter = require('./html_importer'); 4 | var CLIPBOARD_CONTAINER_ID = require('./transformations/copy_selection').CLIPBOARD_CONTAINER_ID; 5 | 6 | function ClipboardImporter(config) { 7 | if (!config.schema) { 8 | throw new Error('Missing argument: config.schema is required.'); 9 | } 10 | _.extend(config, { 11 | trimWhitespaces: true, 12 | REMOVE_INNER_WS: true, 13 | }); 14 | ClipboardImporter.super.call(this, config); 15 | } 16 | 17 | ClipboardImporter.Prototype = function() { 18 | 19 | this.convert = function($rootEl, doc) { 20 | this.initialize(doc, $rootEl); 21 | 22 | var $body = $rootEl.find('body'); 23 | $body = this.sanitizeBody($body); 24 | // TODO: the containerId for the clipboard content should be 25 | // shared via a constant (see) 26 | this.convertContainer($body, CLIPBOARD_CONTAINER_ID); 27 | this.finish(); 28 | }; 29 | 30 | this.sanitizeBody = function($body) { 31 | // Look for paragraphs in which is served by GDocs. 32 | var $gdocs = $body.find('b > p'); 33 | if ($gdocs.length) { 34 | $body = $($gdocs[0].parentNode); 35 | } 36 | return $body; 37 | }; 38 | 39 | this.checkQuality = function($rootEl) { 40 | var $body = $rootEl.find('body'); 41 | // TODO: proper GDocs detection 42 | if ($body.children('b').children('p').length) { 43 | return true; 44 | } 45 | // Are there any useful block-level elements? 46 | // For example this works if you copy'n'paste a set of paragraphs from a wikipedia page 47 | if ($body.children('p').length) { 48 | return true; 49 | } 50 | // if we have paragraphs on a deeper level, it is fishy 51 | if ($body.find('* p').length) { 52 | return false; 53 | } 54 | if ($body.children('a,b,i,strong,italic').length) { 55 | return true; 56 | } 57 | // TODO: how does the content for inline data look like? 58 | return false; 59 | }; 60 | 61 | }; 62 | 63 | OO.inherit(ClipboardImporter, HtmlImporter); 64 | 65 | module.exports = ClipboardImporter; 66 | -------------------------------------------------------------------------------- /document/coordinate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var OO = require('../basics/oo'); 4 | var _ = require('../basics/helpers'); 5 | 6 | // path: the address of a property, such as ['text_1', 'content'] 7 | // offset: the position in the property 8 | // after: an internal flag indicating if the address should be associated to the left or right side 9 | // Note: at boundaries of annotations there are two possible positions with the same address 10 | // foo bar ... 11 | // With offset=7 normally we associate this position: 12 | // foo bar| ... 13 | // With after=true we can describe this position: 14 | // foo bar| ... 15 | function Coordinate(path, offset, after) { 16 | this.path = path; 17 | this.offset = offset; 18 | this.after = after; 19 | if (!_.isArray(path)) { 20 | throw new Error('Invalid arguments: path should be an array.'); 21 | } 22 | if (!_.isNumber(offset) || offset < 0) { 23 | throw new Error('Invalid arguments: offset must be a positive number.'); 24 | } 25 | // make sure that path can't be changed afterwards 26 | if (!Object.isFrozen(path)) { 27 | Object.freeze(path); 28 | } 29 | Object.freeze(this); 30 | } 31 | 32 | Coordinate.Prototype = function() { 33 | 34 | this.equals = function(other) { 35 | return (other === this || 36 | (_.isArrayEqual(other.path, this.path) && other.offset === this.offset) ); 37 | }; 38 | 39 | this.withCharPos = function(offset) { 40 | return new Coordinate(this.path, offset); 41 | }; 42 | 43 | this.getNodeId = function() { 44 | return this.path[0]; 45 | }; 46 | 47 | this.getPath = function() { 48 | return this.path; 49 | }; 50 | 51 | this.getOffset = function() { 52 | return this.offset; 53 | }; 54 | 55 | }; 56 | 57 | OO.initClass( Coordinate ); 58 | 59 | module.exports = Coordinate; -------------------------------------------------------------------------------- /document/document_change.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Substance = require('../basics'); 4 | var PathAdapter = Substance.PathAdapter; 5 | 6 | function DocumentChange(ops, before, after) { 7 | this.id = Substance.uuid(); 8 | this.ops = ops.slice(0); 9 | this.before = before; 10 | this.after = after; 11 | this.updated = null; 12 | this.created = null; 13 | this.deleted = null; 14 | this._init(); 15 | Object.freeze(this); 16 | Object.freeze(this.ops); 17 | Object.freeze(this.before); 18 | Object.freeze(this.after); 19 | // FIXME: ATM this is not possible, as NotifyPropertyChange monkey patches this info 20 | // Object.freeze(this.updated); 21 | // Object.freeze(this.deleted); 22 | // Object.freeze(this.created); 23 | } 24 | 25 | DocumentChange.Prototype = function() { 26 | 27 | this._init = function() { 28 | var ops = this.ops; 29 | var created = {}; 30 | var deleted = {}; 31 | var updated = new PathAdapter.Arrays(); 32 | var i; 33 | for (i = 0; i < ops.length; i++) { 34 | var op = ops[i]; 35 | if (op.type === "create") { 36 | created[op.val.id] = op.val; 37 | delete deleted[op.val.id]; 38 | } 39 | if (op.type === "delete") { 40 | delete created[op.val.id]; 41 | delete updated[op.val.id]; 42 | deleted[op.val.id] = op.val; 43 | } 44 | if (op.type === "set" || op.type === "update") { 45 | // The old as well the new one is affected 46 | updated.add(op.path, op); 47 | } 48 | } 49 | this.created = created; 50 | this.deleted = deleted; 51 | this.updated = updated; 52 | }; 53 | 54 | this.isAffected = function(path) { 55 | return !!this.updated.get(path); 56 | }; 57 | 58 | this.isUpdated = this.isAffected; 59 | 60 | this.invert = function() { 61 | var ops = []; 62 | for (var i = this.ops.length - 1; i >= 0; i--) { 63 | ops.push(this.ops[i].invert()); 64 | } 65 | var before = this.after; 66 | var after = this.before; 67 | return new DocumentChange(ops, before, after); 68 | }; 69 | 70 | this.traverse = function(fn, ctx) { 71 | this.updated.traverse(function() { 72 | fn.apply(ctx, arguments); 73 | }); 74 | }; 75 | 76 | this.getUpdates = function(path) { 77 | return this.updated.get(path) || []; 78 | }; 79 | 80 | this.getCreated = function() { 81 | return this.created; 82 | }; 83 | 84 | this.getDeleted = function() { 85 | return this.deleted; 86 | }; 87 | 88 | }; 89 | 90 | Substance.initClass(DocumentChange); 91 | 92 | module.exports = DocumentChange; 93 | -------------------------------------------------------------------------------- /document/document_schema.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Substance = require('../basics'); 4 | var Data = require('../data'); 5 | 6 | var Node = require('./node'); 7 | var Annotation = require('./annotation'); 8 | var Container = require('./container'); 9 | var ContainerAnnotation = require('./container_annotation'); 10 | 11 | function DocumentSchema(name, version) { 12 | DocumentSchema.super.call(this, name, version); 13 | } 14 | 15 | DocumentSchema.Prototype = function() { 16 | 17 | this.getDefaultTextType = function() { 18 | throw new Error('DocumentSchema.getDefaultTextType() is abstract and must be overridden.'); 19 | }; 20 | 21 | this.isAnnotationType = function(type) { 22 | var nodeClass = this.getNodeClass(type); 23 | return (nodeClass && nodeClass.prototype instanceof Annotation); 24 | }; 25 | 26 | this.getBuiltIns = function() { 27 | return [ Node, Annotation, Container, ContainerAnnotation ]; 28 | }; 29 | 30 | }; 31 | 32 | Substance.inherit( DocumentSchema, Data.Schema ); 33 | 34 | module.exports = DocumentSchema; 35 | -------------------------------------------------------------------------------- /document/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Document = require('./document'); 4 | 5 | Document.Schema = require('./document_schema'); 6 | 7 | Document.Node = require('./node'); 8 | Document.Annotation = require('./annotation'); 9 | Document.Container = require('./container'); 10 | Document.ContainerAnnotation = require('./container_annotation'); 11 | Document.TextNode = require('./text_node'); 12 | 13 | Document.Coordinate = require('./coordinate'); 14 | Document.Range = require('./range'); 15 | Document.Selection = require('./selection'); 16 | Document.nullSelection = Document.Selection.nullSelection; 17 | Document.PropertySelection = require('./property_selection'); 18 | Document.ContainerSelection = require('./container_selection'); 19 | Document.TableSelection = require('./table_selection'); 20 | 21 | Document.Annotator = require('./annotator'); 22 | Document.AnnotationUpdates = require('./annotation_updates'); 23 | 24 | Document.HtmlImporter = require('./html_importer'); 25 | Document.HtmlExporter = require('./html_exporter'); 26 | Document.ClipboardImporter = require('./clipboard_importer'); 27 | Document.ClipboardExporter = require('./clipboard_exporter'); 28 | 29 | // Standard node implementations 30 | Document.Include = require('./nodes/include'); 31 | Document.Paragraph = require('./nodes/paragraph'); 32 | Document.Heading = require('./nodes/heading'); 33 | Document.Emphasis = require('./nodes/emphasis'); 34 | Document.Strong = require('./nodes/strong'); 35 | Document.Link = require('./nodes/link'); 36 | Document.Table = require('./nodes/table'); 37 | Document.TableSection = require('./nodes/table_section'); 38 | Document.TableRow = require('./nodes/table_row'); 39 | Document.TableCell = require('./nodes/table_cell'); 40 | Document.List = require('./nodes/list'); 41 | Document.ListItem = require('./nodes/list_item'); 42 | 43 | Document.Transformations = require('./transformations'); 44 | 45 | module.exports = Document; 46 | -------------------------------------------------------------------------------- /document/node.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('../basics'); 4 | var Data = require('../data'); 5 | 6 | var Node = Data.Node.extend({ 7 | 8 | name: "node", 9 | 10 | attach: function(document) { 11 | this.document = document; 12 | this.didAttach(document); 13 | }, 14 | 15 | detach: function() { 16 | var doc = this.document; 17 | this.document = null; 18 | this.didDetach(doc); 19 | }, 20 | 21 | didAttach: function() {}, 22 | 23 | didDetach: function() {}, 24 | 25 | isAttached: function() { 26 | return this.document !== null; 27 | }, 28 | 29 | getDocument: function() { 30 | return this.document; 31 | }, 32 | 33 | hasParent: function() { 34 | return !!this.parent; 35 | }, 36 | 37 | getParent: function() { 38 | return this.document.get(this.parent); 39 | }, 40 | 41 | getRoot: function() { 42 | var node = this; 43 | while (node.hasParent()) { 44 | node = node.getParent(); 45 | } 46 | return node; 47 | }, 48 | 49 | getComponents: function() { 50 | var componentNames = this.constructor.static.components || []; 51 | if (componentNames.length === 0) { 52 | console.warn('Contract: a node must define its editable properties.', this.constructor.static.name); 53 | } 54 | return componentNames; 55 | }, 56 | 57 | isExternal: function() { 58 | return this.constructor.static.external; 59 | }, 60 | 61 | // Note: children are provided for inline nodes only. 62 | toHtml: function(converter, children) { 63 | return this.constructor.static.toHtml(this, converter, children); 64 | }, 65 | 66 | }); 67 | 68 | Node.initNodeClass = Data.Node.initNodeClass; 69 | 70 | // default HTML serialization 71 | Node.static.toHtml = function(node, converter) { 72 | var $el = $('
') 73 | .attr('data-id', node.id) 74 | .attr('data-type', node.type); 75 | _.each(node.properties, function(value, name) { 76 | var $prop = $('
').attr('itemprop', name); 77 | if (node.getPropertyType === 'string') { 78 | $prop[0].appendChild(converter.annotatedText([node.id, name])); 79 | } else { 80 | $prop.text(value); 81 | } 82 | $el.append($prop); 83 | }); 84 | return $el; 85 | }; 86 | 87 | Node.static.external = false; 88 | 89 | module.exports = Node; 90 | -------------------------------------------------------------------------------- /document/nodes/emphasis.js: -------------------------------------------------------------------------------- 1 | var Annotation = require('../annotation'); 2 | 3 | var Emphasis = Annotation.extend({ 4 | name: "emphasis", 5 | 6 | splitContainerSelections: true 7 | }); 8 | 9 | Emphasis.static.tagName = "em"; 10 | 11 | Emphasis.static.matchElement = function($el) { 12 | return $el.is("em,i"); 13 | }; 14 | 15 | module.exports = Emphasis; 16 | -------------------------------------------------------------------------------- /document/nodes/heading.js: -------------------------------------------------------------------------------- 1 | var TextNode = require('../text_node'); 2 | 3 | var Heading = TextNode.extend({ 4 | name: "heading", 5 | properties: { 6 | "level": "number" 7 | } 8 | }); 9 | 10 | // HtmlImporter 11 | 12 | Heading.static.blockType = true; 13 | 14 | Heading.static.tocType = true; 15 | 16 | Heading.static.matchElement = function($el) { 17 | return /^h\d$/.exec($el[0].tagName.toLowerCase()); 18 | }; 19 | 20 | Heading.static.fromHtml = function($el, converter) { 21 | var id = converter.defaultId($el, 'heading'); 22 | var heading = { 23 | id: id, 24 | level: parseInt(''+$el[0].tagName[1], 10), 25 | content: '' 26 | }; 27 | heading.content = converter.annotatedText($el, [id, 'content']); 28 | return heading; 29 | }; 30 | 31 | // HtmlExporter 32 | 33 | Heading.static.toHtml = function(heading, converter) { 34 | var id = heading.id; 35 | var $el = $('') 36 | $el.append(converter.annotatedText([id, 'content'])); 37 | return $el; 38 | }; 39 | 40 | module.exports = Heading; 41 | -------------------------------------------------------------------------------- /document/nodes/include.js: -------------------------------------------------------------------------------- 1 | var DocumentNode = require('../node'); 2 | 3 | var Include = DocumentNode.extend({ 4 | name: "include", 5 | properties: { 6 | "nodeType": "string", 7 | "nodeId": "id" 8 | }, 9 | 10 | getIncludedNode: function() { 11 | return this.getDocument().get(this.nodeId); 12 | }, 13 | }); 14 | 15 | Include.static.components = ['nodeId']; 16 | 17 | Include.static.blockType = true; 18 | 19 | Include.static.matchElement = function($el) { 20 | return $el.is('include'); 21 | }; 22 | 23 | Include.static.fromHtml = function($el, converter) { 24 | var id = converter.defaultId($el, 'include'); 25 | var inc = { 26 | id: id, 27 | nodeId: $el.attr('data-rid'), 28 | nodeType: $el.attr('data-rtype'), 29 | }; 30 | return inc; 31 | }; 32 | 33 | Include.static.toHtml = function(inc, converter) { 34 | var id = inc.id; 35 | var $el = $('') 36 | .attr('id', id) 37 | .attr('data-rtype', inc.nodeType) 38 | .attr('data-rid', inc.nodeId); 39 | return $el; 40 | }; 41 | 42 | module.exports = Include; -------------------------------------------------------------------------------- /document/nodes/link.js: -------------------------------------------------------------------------------- 1 | var Annotation = require('../annotation'); 2 | 3 | var Link = Annotation.extend({ 4 | name: "link", 5 | properties: { 6 | url: 'string', 7 | title: 'string' 8 | } 9 | }); 10 | 11 | // HtmlImporter 12 | 13 | Link.static.tagName = 'a'; 14 | 15 | Link.static.matchElement = function($el) { 16 | return $el.is('a'); 17 | }; 18 | 19 | Link.static.fromHtml = function($el, converter) { 20 | var link = { 21 | url: $el.attr('href'), 22 | title: $el.attr('title') 23 | }; 24 | // Note: we need to call back the converter 25 | // that it can process the element's inner html. 26 | // We do not need it for the link itself, though 27 | // TODO: maybe it is possible to detect if it has called back 28 | converter.annotatedText($el); 29 | return link; 30 | }; 31 | 32 | Link.static.toHtml = function(link, converter, children) { 33 | var $el = Annotation.static.toHtml(link, converter, children); 34 | $el.attr('href', link.url); 35 | $el.attr('title', link.title); 36 | return $el; 37 | }; 38 | 39 | module.exports = Link; 40 | -------------------------------------------------------------------------------- /document/nodes/list.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('../../basics/helpers'); 4 | var DocumentNode = require('../node'); 5 | 6 | var List = DocumentNode.extend({ 7 | name: "list", 8 | properties: { 9 | ordered: "bool", 10 | items: ["array", "id"] 11 | }, 12 | getItems: function() { 13 | var doc = this.getDocument(); 14 | return _.map(this.items, function(id) { 15 | return doc.get(id); 16 | }, this); 17 | }, 18 | }); 19 | 20 | List.static.components = ['items']; 21 | 22 | // HtmlImporter 23 | 24 | List.static.blockType = true; 25 | 26 | List.static.matchElement = function($el) { 27 | return $el.is('ul,ol'); 28 | }; 29 | 30 | List.static.fromHtml = function($el, converter) { 31 | var id = converter.defaultId($el, 'list'); 32 | var list = { 33 | id: id, 34 | ordered: false, 35 | items: [] 36 | }; 37 | if ($el.is('ol')) { 38 | list.ordered = true; 39 | } 40 | // Note: nested lists are not supported yet 41 | var level = 1; 42 | $el.children().each(function() { 43 | var $child = $(this); 44 | if ($child.is('li')) { 45 | var listItem = converter.convertElement($child, { parent: id, level: level }); 46 | list.items.push(listItem.id); 47 | } else { 48 | converter.warning('List: unsupported child element. ' + converter.$toStr($child)); 49 | } 50 | }); 51 | return list; 52 | }; 53 | 54 | List.static.toHtml = function(list, converter) { 55 | var tagName = list.ordered ? 'ol' : 'ul'; 56 | var id = list.id; 57 | var $el = ('<' + tagName + '>') 58 | .attr('id', id); 59 | _.each(list.getItems(), function(item) { 60 | $el.append(item.toHtml(converter)); 61 | }); 62 | return $el; 63 | }; 64 | 65 | Object.defineProperties(List.prototype, { 66 | itemNodes: { 67 | 'get': function() { 68 | return this.getItems(); 69 | } 70 | } 71 | }); 72 | 73 | module.exports = List; 74 | -------------------------------------------------------------------------------- /document/nodes/list_item.js: -------------------------------------------------------------------------------- 1 | var Node = require('../node'); 2 | 3 | var ListItem = Node.extend({ 4 | name: "list-item", 5 | properties: { 6 | parent: "id", 7 | level: "number", 8 | content: "string", 9 | }, 10 | }); 11 | 12 | ListItem.static.components = ['content']; 13 | 14 | // HtmlImporter 15 | 16 | ListItem.static.matchElement = function($el) { 17 | return $el.is('li'); 18 | }; 19 | 20 | ListItem.static.fromHtml = function($el, converter) { 21 | var level = $el.data('level') || 1; 22 | var id = converter.defaultId($el, 'li'); 23 | var item = { 24 | id: id, 25 | level: level, 26 | content: '' 27 | }; 28 | item.content = converter.annotatedText($el, [id, 'content']); 29 | return item; 30 | }; 31 | 32 | ListItem.static.toHtml = function(item, converter) { 33 | var id = item.id; 34 | var $el = $('
  • ') 35 | .attr('id', item.id) 36 | .data('level', item.level) 37 | .append(converter.annotatedText([id, 'content'])) 38 | return $el; 39 | }; 40 | 41 | module.exports = ListItem; 42 | -------------------------------------------------------------------------------- /document/nodes/paragraph.js: -------------------------------------------------------------------------------- 1 | var TextNode = require('../text_node'); 2 | 3 | var Paragraph = TextNode.extend({ 4 | name: "paragraph" 5 | }); 6 | 7 | // HtmlImporter 8 | 9 | Paragraph.static.blockType = true; 10 | 11 | Paragraph.static.matchElement = function($el) { 12 | return $el.is('p'); 13 | }; 14 | 15 | Paragraph.static.fromHtml = function($el, converter) { 16 | var id = converter.defaultId($el, 'p'); 17 | var paragraph = { 18 | id: id, 19 | content: '' 20 | }; 21 | paragraph.content = converter.annotatedText($el, [id, 'content']); 22 | return paragraph; 23 | }; 24 | 25 | // HtmlExporter 26 | 27 | Paragraph.static.toHtml = function(paragraph, converter) { 28 | var id = paragraph.id; 29 | var $el = $('

    ') 30 | .attr('id', id); 31 | $el.append(converter.annotatedText([id, 'content'])); 32 | return $el; 33 | }; 34 | 35 | module.exports = Paragraph; 36 | -------------------------------------------------------------------------------- /document/nodes/strong.js: -------------------------------------------------------------------------------- 1 | var Annotation = require('../annotation'); 2 | 3 | var Strong = Annotation.extend({ 4 | name: "strong", 5 | 6 | // this means that it will annotate also when you have 7 | // selected multiple paragraphs, creating a single annotation 8 | // for every paragraph 9 | splitContainerSelections: true 10 | 11 | }); 12 | 13 | Strong.static.tagName = 'strong'; 14 | 15 | Strong.static.matchElement = function($el) { 16 | return $el.is('strong,b'); 17 | }; 18 | 19 | module.exports = Strong; 20 | -------------------------------------------------------------------------------- /document/nodes/table_cell.js: -------------------------------------------------------------------------------- 1 | var Node = require('../node'); 2 | 3 | var TableCell = Node.extend({ 4 | name: "table-cell", 5 | properties: { 6 | "parent": "id", 7 | "cellType": "string", // "head" or "data" 8 | "colspan": "number", 9 | "rowspan": "number", 10 | "content": "string" 11 | }, 12 | getSpan: function(dim) { 13 | if (dim === "col") { 14 | return this.colspan || 1; 15 | } else if (dim === "row") { 16 | return this.rowspan || 1; 17 | } 18 | } 19 | }); 20 | 21 | TableCell.static.components = ['content']; 22 | 23 | // HtmlImporter 24 | 25 | TableCell.static.matchElement = function($el) { 26 | return $el.is('th, td'); 27 | }; 28 | 29 | TableCell.static.fromHtml = function($el, converter) { 30 | var id = converter.defaultId($el, 'tcell'); 31 | var tableCell = { 32 | id: id, 33 | content: "" 34 | }; 35 | if ($el.is('th')) { 36 | tableCell.cellType = "head"; 37 | } else { 38 | tableCell.cellType = "data"; 39 | } 40 | var colspan = $el.attr('colspan'); 41 | if (colspan) { 42 | tableCell.colspan = parseInt(colspan, 10); 43 | } 44 | var rowspan = $el.attr('rowspan'); 45 | if (rowspan) { 46 | tableCell.rowspan = parseInt(rowspan, 10); 47 | } 48 | tableCell.content = converter.annotatedText($el, [id, 'content']); 49 | return tableCell; 50 | }; 51 | 52 | TableCell.static.toHtml = function(cell, converter) { 53 | var id = cell.id; 54 | var tagName = (cell.cellType==="head" ? "th" : "td"); 55 | var $el = $('<' + tagName + '>') 56 | .attr('id', 'id') 57 | .append(converter.annotatedText([id, 'content'])); 58 | return $el; 59 | }; 60 | 61 | Object.defineProperties(TableCell.prototype, { 62 | isData: { 63 | 'get': function() { 64 | return this.cellType === "data"; 65 | } 66 | } 67 | }); 68 | 69 | module.exports = TableCell; 70 | -------------------------------------------------------------------------------- /document/nodes/table_row.js: -------------------------------------------------------------------------------- 1 | var Node = require('../node'); 2 | var _ = require('../../basics/helpers'); 3 | 4 | var TableRow = Node.extend({ 5 | name: "table-row", 6 | properties: { 7 | "parent": "id", 8 | "cells": ["array", "id"] 9 | }, 10 | getCells: function() { 11 | var doc = this.getDocument(); 12 | return _.map(this.cells, function(id) { 13 | return doc.get(id); 14 | }, this); 15 | }, 16 | getCellAt: function(cellIdx) { 17 | var doc = this.getDocument(); 18 | var cellId = this.cells[cellIdx]; 19 | if (cellId) { 20 | return doc.get(cellId); 21 | } else { 22 | return null; 23 | } 24 | }, 25 | }); 26 | 27 | TableRow.static.components = ['cells']; 28 | 29 | 30 | // HtmlImporter 31 | 32 | TableRow.static.matchElement = function($el) { 33 | return $el.is('tr'); 34 | }; 35 | 36 | TableRow.static.fromHtml = function($el, converter) { 37 | var id = converter.defaultId($el, 'tr'); 38 | var tableRow = { 39 | id: id, 40 | cells: [] 41 | }; 42 | $el.find('th,td').each(function() { 43 | var $cell = $(this); 44 | var cellNode = converter.convertElement($cell, { parent: id }); 45 | tableRow.cells.push(cellNode.id); 46 | }); 47 | return tableRow; 48 | }; 49 | 50 | TableRow.static.toHtml = function(row, converter) { 51 | var id = row.id; 52 | var $el = $('').attr('id', id); 53 | _.each(row.getCells(), function(cell) { 54 | $el.append(cell.toHtml(converter)); 55 | }); 56 | return $el; 57 | }; 58 | 59 | Object.defineProperties(TableRow.prototype, { 60 | cellNodes: { 61 | 'get': function() { 62 | return this.getCells(); 63 | } 64 | } 65 | }); 66 | 67 | module.exports = TableRow; 68 | -------------------------------------------------------------------------------- /document/nodes/table_section.js: -------------------------------------------------------------------------------- 1 | var Node = require('../node'); 2 | var _ = require('../../basics/helpers'); 3 | 4 | var TableSection = Node.extend({ 5 | name: "table-section", 6 | properties: { 7 | "parent": "id", 8 | "rows": ["array", "id"], 9 | "sectionType": "string", 10 | }, 11 | getRows: function() { 12 | var doc = this.getDocument(); 13 | return _.map(this.rows, function(id) { 14 | return doc.get(id); 15 | }, this); 16 | }, 17 | getRowAt: function(rowIdx) { 18 | var doc = this.getDocument(); 19 | var rowId = this.rows[rowIdx]; 20 | if (rowId) { 21 | return doc.get(rowId); 22 | } else { 23 | return null; 24 | } 25 | }, 26 | }); 27 | 28 | TableSection.static.components = ['rows']; 29 | 30 | 31 | // HtmlImporter 32 | 33 | TableSection.static.matchElement = function($el) { 34 | return $el.is('thead, tbody, tfoot'); 35 | }; 36 | 37 | TableSection.static.fromHtml = function($el, converter) { 38 | var tagName = $el[0].tagName.toLowerCase(); 39 | var sectionType = tagName.substring(1); 40 | var id = converter.defaultId($el, tagName); 41 | var tableSection = { 42 | id: id, 43 | sectionType: sectionType, 44 | rows: [] 45 | }; 46 | $el.find('tr').each(function() { 47 | var $row = $(this); 48 | var rowNode = converter.convertElement($row, { parent: id }); 49 | tableSection.rows.push(rowNode.id); 50 | }); 51 | return tableSection; 52 | }; 53 | 54 | TableSection.static.toHtml = function(sec, converter) { 55 | var id = sec.id; 56 | var $el = $('') 57 | .attr('id', id); 58 | _.each(sec.getRows(), function(row) { 59 | $el.append(row.toHtml(converter)); 60 | }); 61 | return $el; 62 | }; 63 | 64 | Object.defineProperties(TableSection.prototype, { 65 | cellNodes: { 66 | 'get': function() { 67 | return this.getCells(); 68 | } 69 | } 70 | }); 71 | 72 | module.exports = TableSection; 73 | -------------------------------------------------------------------------------- /document/path_event_proxy.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('../basics/helpers'); 4 | var OO = require('../basics/oo'); 5 | var PathAdapter = require('../basics/path_adapter'); 6 | 7 | var NotifyByPathProxy = function(doc) { 8 | this.listeners = new PathAdapter(); 9 | this._list = []; 10 | this.doc = doc; 11 | }; 12 | 13 | NotifyByPathProxy.Prototype = function() { 14 | 15 | this.onDocumentChanged = function(change, info, doc) { 16 | var listeners = this.listeners; 17 | var updated = change.updated; 18 | 19 | function _updated(path, op) { 20 | if (!change.deleted[path[0]]) { 21 | updated.add(path, op); 22 | } 23 | } 24 | 25 | function _updatedContainerAnno(containerId, startPath, endPath, op) { 26 | var container = doc.get(containerId); 27 | var startComp = container.getComponent(startPath); 28 | var endComp = container.getComponent(endPath); 29 | if (startComp && endComp) { 30 | var startIdx = startComp.getIndex(); 31 | var endIdx = endComp.getIndex(); 32 | var comp = startComp; 33 | for (var i = startIdx; comp && i <= endIdx; i++, comp = comp.getNext()) { 34 | _updated(comp.getPath(), op); 35 | } 36 | } else { 37 | _updated(startPath, op); 38 | _updated(endPath, op); 39 | } 40 | } 41 | 42 | _.each(change.ops, function(op) { 43 | if ( (op.type === "create" || op.type === "delete") && (op.val.path || op.val.startPath)) { 44 | if (op.val.path) { 45 | _updated(op.val.path, op); 46 | } else if (op.val.startPath) { 47 | _updatedContainerAnno(op.val.container, op.val.startPath, op.val.endPath, op); 48 | } 49 | } 50 | else if (op.type === "set" && (op.path[1] === "path" || op.path[1] === "startPath" || op.path[1] === "endPath")) { 51 | _updated(op.val, op); 52 | _updated(op.original, op); 53 | } 54 | else if (op.type === "set" && (op.path[1] === "startOffset" || op.path[1] === "endOffset")) { 55 | var anno = this.doc.get(op.path[0]); 56 | if (anno) { 57 | if (anno.path) { 58 | _updated(anno.path, op); 59 | } else { 60 | _updatedContainerAnno(anno.container, anno.startPath, anno.endPath, op); 61 | } 62 | } 63 | } 64 | }, this); 65 | change.traverse(function(path) { 66 | var key = path.concat(['listeners']); 67 | var scopedListeners = listeners.get(key); 68 | _.each(scopedListeners, function(entry) { 69 | entry.method.call(entry.listener, change, info, doc); 70 | }); 71 | }, this); 72 | }; 73 | 74 | this.add = function(path, listener, method) { 75 | var key = path.concat(['listeners']); 76 | var listeners = this.listeners.get(key); 77 | if (!listeners) { 78 | listeners = []; 79 | this.listeners.set(key, listeners); 80 | } 81 | if (!method) { 82 | throw new Error('Invalid argument: expected function but got ' + method); 83 | } 84 | listeners.push({ method: method, listener: listener }); 85 | }; 86 | 87 | this.connect = function(listener, path, method) { 88 | this.add(path, listener, method); 89 | }; 90 | 91 | // TODO: it would be cool if we would just need to provide the listener instance, no path 92 | this.remove = function(path, listener) { 93 | var key = path.concat(['listeners']); 94 | var listeners = this.listeners.get(key); 95 | if (listeners) { 96 | for (var i = 0; i < listeners.length; i++) { 97 | if (listeners[i].listener === listener) { 98 | listeners.splice(i, 1); 99 | return; 100 | } 101 | } 102 | } 103 | }; 104 | 105 | this.disconnect = function(listener, path) { 106 | this.remove(path, listener); 107 | }; 108 | 109 | }; 110 | 111 | OO.initClass(NotifyByPathProxy); 112 | 113 | module.exports = NotifyByPathProxy; 114 | -------------------------------------------------------------------------------- /document/range.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Substance = require('../basics'); 4 | 5 | var Range = function(start, end) { 6 | this.start = start; 7 | this.end = end; 8 | Object.freeze(this); 9 | }; 10 | 11 | Range.Prototype = function() { 12 | 13 | this.isCollapsed = function() { 14 | return this.start.equals(this.end); 15 | }; 16 | 17 | this.equals = function(other) { 18 | if (this === other) return true; 19 | else return (this.start.equals(other.start) && this.end.equals(other.end)); 20 | }; 21 | 22 | }; 23 | 24 | Substance.initClass(Range); 25 | 26 | module.exports = Range; 27 | -------------------------------------------------------------------------------- /document/selection.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Substance = require('../basics'); 4 | 5 | function Selection() { 6 | } 7 | 8 | Selection.Prototype = function() { 9 | 10 | this.getRanges = function() { 11 | return []; 12 | }; 13 | 14 | this.isNull = function() { 15 | return false; 16 | }; 17 | 18 | this.isMultiSeletion = function() { 19 | return false; 20 | }; 21 | 22 | this.isPropertySelection = function() { 23 | return false; 24 | }; 25 | 26 | this.isContainerSelection = function() { 27 | return false; 28 | }; 29 | 30 | this.isTableSelection = function() { 31 | return false; 32 | }; 33 | 34 | this.isCollapsed = function() { 35 | return true; 36 | }; 37 | 38 | this.isReverse = function() { 39 | return false; 40 | }; 41 | 42 | this.equals = function(other) { 43 | if (this === other) { 44 | return true ; 45 | } else if (!other) { 46 | return false; 47 | } else if (this.isNull() !== other.isNull()) { 48 | return false; 49 | } else { 50 | return true; 51 | } 52 | }; 53 | 54 | this.toString = function() { 55 | return "null"; 56 | }; 57 | 58 | }; 59 | 60 | Substance.initClass(Selection); 61 | 62 | var NullSelection = function() {}; 63 | NullSelection.Prototype = function() { 64 | this.isNull = function() { 65 | return true; 66 | }; 67 | }; 68 | Substance.inherit(NullSelection, Selection); 69 | Selection.nullSelection = Object.freeze(new NullSelection()); 70 | 71 | module.exports = Selection; 72 | -------------------------------------------------------------------------------- /document/table_selection.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Substance = require('../basics'); 4 | var _ = require('../basics/helpers'); 5 | var Selection = require('./selection'); 6 | 7 | function TableSelection(properties) { 8 | this.tableId = properties.tableId; 9 | if (properties.rectangle) { 10 | this.rectangle = properties.rectangle; 11 | } else { 12 | this.rectangle = new TableSelection.Rectangle(properties.startRow, properties.startCol, 13 | properties.endRow, properties.endCol); 14 | } 15 | if (!this.tableId) { 16 | throw new Error('Invalid arguments. `tableId` is mandatory.'); 17 | } 18 | this._internal = {}; 19 | Object.freeze(this); 20 | } 21 | 22 | TableSelection.Prototype = function() { 23 | 24 | this.isPropertySelection = function() { 25 | return false; 26 | }; 27 | 28 | this.isTableSelection = function() { 29 | return true; 30 | }; 31 | 32 | this.isSingleCell = function() { 33 | return this.rectangle.isSingleCell(); 34 | }; 35 | 36 | this.getTableId = function() { 37 | return this.tableId; 38 | }; 39 | 40 | this.getRectangle = function() { 41 | return this.rectangle; 42 | }; 43 | 44 | this.equals = function(other) { 45 | return (Selection.prototype.equals.call(this, other) && 46 | !other.isTableSelection() && 47 | (this.startRow === other.startRow && this.endRow === other.endRow && 48 | this.startCol === other.startCol && this.ednCol === other.endCol )); 49 | }; 50 | 51 | this.toString = function() { 52 | var r = this.rectangle; 53 | return "T[("+ r.start.row + "," + r.start.col + "), ("+ r.end.row + ", " + r.end.col +")]"; 54 | }; 55 | 56 | this.attach = function(doc) { 57 | this._internal.doc = doc; 58 | return this; 59 | }; 60 | 61 | }; 62 | 63 | Substance.inherit(TableSelection, Selection); 64 | 65 | Object.defineProperties(TableSelection.prototype, { 66 | startRow: { 67 | get: function() { 68 | return this.rectangle.start.row; 69 | } 70 | }, 71 | endRow: { 72 | get: function() { 73 | return this.rectangle.end.row; 74 | } 75 | }, 76 | startCol: { 77 | get: function() { 78 | return this.rectangle.start.col; 79 | } 80 | }, 81 | endCol: { 82 | get: function() { 83 | return this.rectangle.end.col; 84 | } 85 | }, 86 | }); 87 | 88 | TableSelection.Rectangle = function(startRow, startCol, endRow, endCol) { 89 | var minRow = Math.min(startRow, endRow); 90 | var maxRow = Math.max(startRow, endRow); 91 | var minCol = Math.min(startCol, endCol); 92 | var maxCol = Math.max(startCol, endCol); 93 | 94 | this.start = { 95 | row: minRow, 96 | col: minCol 97 | }; 98 | this.end = { 99 | row: maxRow, 100 | col: maxCol 101 | }; 102 | Object.freeze(this.start); 103 | Object.freeze(this.end); 104 | Object.freeze(this); 105 | }; 106 | 107 | TableSelection.Rectangle.prototype.isSingleCell = function() { 108 | return (this.start.row === this.end.row && this.start.col === this.end.col); 109 | }; 110 | 111 | module.exports = TableSelection; 112 | -------------------------------------------------------------------------------- /document/text_node.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Node = require('./node'); 4 | 5 | // Text Node 6 | // --------- 7 | // 8 | // A base class for all text-ish nodes, such as Paragraphs, Headings, 9 | // Prerendered, etc. 10 | 11 | var TextNode = Node.extend({ 12 | name: "text", 13 | properties: { 14 | content: 'string' 15 | }, 16 | }); 17 | 18 | TextNode.static.components = ['content']; 19 | 20 | module.exports = TextNode; 21 | -------------------------------------------------------------------------------- /document/transaction_document.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('../basics/helpers'); 4 | var OO = require('../basics/oo'); 5 | var AbstractDocument = require('./abstract_document'); 6 | 7 | var __id__ = 0; 8 | 9 | function TransactionDocument(document) { 10 | AbstractDocument.call(this, document.schema); 11 | this.__id__ = "TX_"+__id__++; 12 | 13 | this.document = document; 14 | // ops recorded since transaction start 15 | this.ops = []; 16 | // app information state information used to recover the state before the transaction 17 | // when calling undo 18 | this.before = {}; 19 | // HACK: copying all indexes 20 | _.each(document.data.indexes, function(index, name) { 21 | this.data.addIndex(name, index.clone()); 22 | }, this); 23 | 24 | this.loadSeed(document.toJSON()); 25 | } 26 | 27 | TransactionDocument.Prototype = function() { 28 | 29 | this.isTransaction = function() { 30 | return true; 31 | }; 32 | 33 | this.reset = function() { 34 | this.ops = []; 35 | this.before = {}; 36 | this._resetContainers(); 37 | }; 38 | 39 | this.create = function(nodeData) { 40 | var op = this.data.create(nodeData); 41 | if (!op) return; 42 | if (this.document.isTransacting) { 43 | this.ops.push(op); 44 | } 45 | // TODO: incremental graph returns op not the node, 46 | // so probably here we should too? 47 | return this.data.get(nodeData.id); 48 | }; 49 | 50 | this.delete = function(nodeId) { 51 | var op = this.data.delete(nodeId); 52 | if (!op) return; 53 | if (this.document.isTransacting) { 54 | this.ops.push(op); 55 | } 56 | return op; 57 | }; 58 | 59 | this.set = function(path, value) { 60 | var op = this.data.set(path, value); 61 | if (!op) return; 62 | this._updateContainers(op); 63 | if (this.document.isTransacting) { 64 | this.ops.push(op); 65 | } 66 | return op; 67 | }; 68 | 69 | this.update = function(path, diffOp) { 70 | var op = this.data.update(path, diffOp); 71 | if (!op) return; 72 | this._updateContainers(op); 73 | if (this.document.isTransacting) { 74 | this.ops.push(op); 75 | } 76 | return op; 77 | }; 78 | 79 | this.save = function(afterState, info) { 80 | var before = this.before; 81 | var after = _.extend({}, before, afterState); 82 | this.document._saveTransaction(before, after, info); 83 | // reset after finishing 84 | this.reset(); 85 | }; 86 | 87 | this.cancel = function() { 88 | // revert all recorded changes 89 | for (var i = this.ops.length - 1; i >= 0; i--) { 90 | this.data.apply(this.ops[i].invert()); 91 | } 92 | this.document._cancelTransaction(); 93 | this.reset(); 94 | }; 95 | 96 | this.finish = function() { 97 | if (this.document.isTransacting) { 98 | this.cancel(); 99 | } 100 | }; 101 | 102 | this.cleanup = this.finish; 103 | 104 | this.getOperations = function() { 105 | return this.ops; 106 | }; 107 | 108 | this.apply = function(documentChange) { 109 | _.each(documentChange.ops, function(op) { 110 | this.data.apply(op); 111 | this._updateContainers(op); 112 | }, this); 113 | }; 114 | 115 | this.getIndex = function(name) { 116 | return this.data.getIndex(name); 117 | }; 118 | 119 | // Called back by Substance.Data after a node instance has been created 120 | this._didCreateNode = function(node) { 121 | node.document = this; 122 | }; 123 | 124 | this._didDeleteNode = function(node) { 125 | node.document = null; 126 | }; 127 | 128 | this.createSelection = function() { 129 | return this.document.createSelection.apply(this, arguments); 130 | }; 131 | 132 | this.getSchema = function() { 133 | return this.schema; 134 | }; 135 | 136 | }; 137 | 138 | OO.inherit(TransactionDocument, AbstractDocument); 139 | 140 | module.exports = TransactionDocument; 141 | -------------------------------------------------------------------------------- /document/transformations/break_node.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Substance = require('../../basics'); 4 | var deleteSelection = require('./delete_selection'); 5 | var Annotations = require('../annotation_updates'); 6 | 7 | /* jshint latedef: false */ 8 | 9 | /** 10 | * @params args object with fields `selection`, `containerId` 11 | */ 12 | function breakNode(tx, args) { 13 | if (!args.selection) { 14 | throw new Error("Argument 'selection' is mandatory."); 15 | } 16 | if (!args.containerId) { 17 | throw new Error("Argument 'containerId' is mandatory."); 18 | } 19 | if (!args.selection.isCollapsed()) { 20 | var out = deleteSelection(tx, args); 21 | args.selection = out.selection; 22 | } 23 | var range = args.selection.getRange(); 24 | var node = tx.get(range.start.path[0]); 25 | // TODO: we want to allow custom break behaviors 26 | // for that to happen we need to learn more 27 | if (node.isInstanceOf('text')) { 28 | return breakTextNode(tx, args); 29 | } else { 30 | console.info("Breaking is not supported for node type %s.", node.type); 31 | return args; 32 | } 33 | } 34 | 35 | function breakTextNode(tx, args) { 36 | var selection = args.selection; 37 | var containerId = args.containerId; 38 | if (!selection.isPropertySelection()) { 39 | throw new Error('Expected property selection.'); 40 | } 41 | var range = selection.getRange(); 42 | var path = range.start.path; 43 | var offset = range.start.offset; 44 | var node = tx.get(path[0]); 45 | 46 | // split the text property and create a new paragraph node with trailing text and annotations transferred 47 | var text = node.content; 48 | var container = tx.get(containerId); 49 | var nodePos = container.getPosition(node.id); 50 | var id = Substance.uuid(node.type); 51 | var newPath = [id, 'content']; 52 | var newNode; 53 | // when breaking at the first position, a new node of the same 54 | // type will be inserted. 55 | if (offset === 0) { 56 | newNode = tx.create({ 57 | id: id, 58 | type: node.type, 59 | content: "" 60 | }); 61 | // show the new node 62 | container.show(id, nodePos); 63 | selection = tx.createSelection({ 64 | type: 'property', 65 | path: path, 66 | startOffset: 0 67 | }); 68 | } 69 | // otherwise a default text type node is inserted 70 | else { 71 | // create a new node 72 | newNode = tx.create({ 73 | id: id, 74 | type: tx.getSchema().getDefaultTextType(), 75 | content: text.substring(offset) 76 | }); 77 | if (offset < text.length) { 78 | // transfer annotations which are after offset to the new node 79 | Annotations.transferAnnotations(tx, path, offset, [id, 'content'], 0); 80 | // truncate the original property 81 | tx.update(path, { 82 | delete: { start: offset, end: text.length } 83 | }); 84 | } 85 | // show the new node 86 | container.show(id, nodePos+1); 87 | // update the selection 88 | selection = tx.createSelection({ 89 | type: 'property', 90 | path: newPath, 91 | startOffset: 0 92 | }); 93 | } 94 | return { 95 | selection: selection, 96 | node: newNode 97 | }; 98 | } 99 | 100 | module.exports = breakNode; 101 | -------------------------------------------------------------------------------- /document/transformations/delete_character.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Annotations = require('../annotation_updates'); 4 | var merge = require('./merge'); 5 | 6 | /** 7 | * The behavior when you press delete or backspace. 8 | * I.e., it starts with a collapsed PropertySelection and deletes the character before 9 | * or after the caret. 10 | * If the caret is at the begin or end it will call `mergeNodes`. 11 | */ 12 | var deleteCharacter = function(tx, args) { 13 | var selection = args.selection; 14 | var direction = args.direction; 15 | var range = selection.getRange(); 16 | var startChar, endChar; 17 | if (!selection.isCollapsed()) { 18 | throw new Error('Selection must be collapsed for transformation "deleteCharacter"'); 19 | } 20 | var prop = tx.get(range.start.path); 21 | if ((range.start.offset === 0 && direction === 'left') || 22 | (range.start.offset === prop.length && direction === 'right')) { 23 | var result = merge(tx, { 24 | selection: selection, 25 | containerId: args.containerId, 26 | path: range.start.path, 27 | direction: direction 28 | }); 29 | selection = result.selection; 30 | } else { 31 | // simple delete one character 32 | startChar = (direction === 'left') ? range.start.offset-1 : range.start.offset; 33 | endChar = startChar+1; 34 | tx.update(range.start.path, { delete: { start: startChar, end: endChar } }); 35 | Annotations.deletedText(tx, range.start.path, startChar, endChar); 36 | selection = tx.createSelection({ 37 | type: 'property', 38 | path: range.start.path, 39 | startOffset: startChar 40 | }); 41 | } 42 | return { selection: selection }; 43 | }; 44 | 45 | module.exports = deleteCharacter; 46 | -------------------------------------------------------------------------------- /document/transformations/delete_node.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('../../basics/helpers'); 4 | 5 | /** 6 | * Delete a node and all annotations attached to it, 7 | * and removes the node from all containers. 8 | * 9 | * @param args object with fields: `nodeId`. 10 | */ 11 | function deleteNode(tx, args) { 12 | if (!args.nodeId) { 13 | throw new Error('Parameter `nodeId` is mandatory.'); 14 | } 15 | var nodeId = args.nodeId; 16 | // remove all associated annotations 17 | var annos = tx.getIndex('annotations').get(nodeId); 18 | var i; 19 | for (i = 0; i < annos.length; i++) { 20 | tx.delete(annos[i].id); 21 | } 22 | // We need to transfer anchors of ContainerAnnotations 23 | // to previous or next node 24 | var anchors = tx.getIndex('container-annotation-anchors').get(nodeId); 25 | for (i = 0; i < anchors.length; i++) { 26 | var anchor = anchors[i]; 27 | var container = tx.get(anchor.container); 28 | // Note: during the course of this loop we might have deleted the node already 29 | // so, do not do it again 30 | if (!tx.get(anchor.id)) continue; 31 | var comp = container.getComponent(anchor.path); 32 | if (anchor.isStart) { 33 | if (comp.hasNext()) { 34 | tx.set([anchor.id, 'startPath'], comp.next.path); 35 | tx.set([anchor.id, 'startOffset'], 0); 36 | } else { 37 | tx.delete(anchor.id); 38 | } 39 | } else { 40 | if (comp.hasPrevious()) { 41 | var prevLength = tx.get(comp.previous.path).length; 42 | tx.set([anchor.id, 'endPath'], comp.previous.path); 43 | tx.set([anchor.id, 'endOffset'], prevLength); 44 | } else { 45 | tx.delete(anchor.id); 46 | } 47 | } 48 | } 49 | _.each(tx.getIndex('type').get('container'), function(container) { 50 | // remove from view first 51 | container.hide(nodeId); 52 | }); 53 | // and then delete permanently 54 | tx.delete(nodeId); 55 | } 56 | 57 | module.exports = deleteNode; 58 | -------------------------------------------------------------------------------- /document/transformations/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | breakNode: require('./break_node'), 3 | copySelection: require('./copy_selection'), 4 | deleteCharacter: require('./delete_character'), 5 | deleteNode: require('./delete_node'), 6 | deleteSelection: require('./delete_selection'), 7 | insertNode: require('./insert_node'), 8 | insertText: require('./insert_text'), 9 | merge: require('./merge'), 10 | paste: require('./paste'), 11 | switchTextType: require('./switch_text_type'), 12 | }; 13 | -------------------------------------------------------------------------------- /document/transformations/insert_node.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var deleteSelection = require('./delete_selection'); 4 | var breakNode = require('./break_node'); 5 | 6 | function insertNode(tx, args) { 7 | var selection = args.selection; 8 | var node = args.node; 9 | 10 | if (!args.containerId) { 11 | throw new Error("containerId is mandatory"); 12 | } 13 | if (!args.selection) { 14 | throw new Error("selection is mandatory"); 15 | } 16 | if (!args.node) { 17 | throw new Error("node is mandatory"); 18 | } 19 | 20 | var containerId = args.containerId; 21 | 22 | var container = tx.get(containerId); 23 | var result; 24 | if (!selection.isCollapsed()) { 25 | result = deleteSelection(tx, args); 26 | selection = result.selection; 27 | } 28 | result = breakNode(tx, args); 29 | selection = result.selection; 30 | if (!tx.get(node.id)) { 31 | node = tx.create(node); 32 | } 33 | var comp = container.getComponent(selection.start.path); 34 | var pos = container.getPosition(comp.rootId); 35 | container.show(node.id, pos); 36 | // TODO: set cursor to first position of inserted node 37 | return { 38 | selection: null 39 | }; 40 | } 41 | 42 | module.exports = insertNode; 43 | -------------------------------------------------------------------------------- /document/transformations/insert_text.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var deleteSelection = require('./delete_selection'); 4 | var Annotations = require('../annotation_updates'); 5 | 6 | var insertText = function(tx, args) { 7 | var selection = args.selection; 8 | var text = args.text; 9 | if (!selection) { 10 | throw new Error('Argument `selection` is mandatory for transformation `insertText`.'); 11 | } 12 | if (!text) { 13 | throw new Error('Argument `text` is mandatory for transformation `insertText`.'); 14 | } 15 | if (!(selection.isPropertySelection() || selection.isContainerSelection())) { 16 | throw new Error('Selection must be property or container selection.') 17 | } 18 | var result; 19 | if (!selection.isCollapsed()) { 20 | result = deleteSelection(tx, { 21 | selection: selection, 22 | direction: 'right' 23 | }); 24 | selection = result.selection; 25 | } 26 | var range = selection.getRange(); 27 | // HACK(?): if the string property is not initialized yet we do it here 28 | // for convenience. 29 | if (tx.get(range.start.path) === undefined) { 30 | tx.set(range.start.path, ""); 31 | } 32 | tx.update(range.start.path, { insert: { offset: range.start.offset, value: text } } ); 33 | Annotations.insertedText(tx, range.start, text.length); 34 | return { 35 | selection: tx.createSelection({ 36 | type: 'property', 37 | path: range.start.path, 38 | startOffset: range.start.offset + text.length 39 | }) 40 | }; 41 | }; 42 | 43 | module.exports = insertText; 44 | -------------------------------------------------------------------------------- /document/transformations/merge.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Annotations = require('../annotation_updates'); 4 | 5 | /* jshint latedef: false */ 6 | 7 | // low-level merge implementation 8 | var merge = function(tx, args) { 9 | var containerId = args.containerId; 10 | var path = args.path; 11 | var direction = args.direction; 12 | if (!containerId||!path||!direction) { 13 | throw new Error('Insufficient arguments! mandatory fields: `containerId`, `path`, `direction`'); 14 | } 15 | var container = tx.get(containerId); 16 | var component = container.getComponent(path); 17 | if (direction === 'right' && component.next) { 18 | return _mergeComponents(tx, containerId, component, component.next); 19 | } else if (direction === 'left' && component.previous) { 20 | return _mergeComponents(tx, containerId, component.previous, component); 21 | } else { 22 | // No behavior defined for this merge 23 | } 24 | }; 25 | 26 | var _mergeComponents = function(tx, containerId, firstComp, secondComp) { 27 | var firstNode = tx.get(firstComp.parentNode.id); 28 | var secondNode = tx.get(secondComp.parentNode.id); 29 | // TODO: it should be possible to extend the merge transformation by providing custom transformations 30 | // for nodes anc components 31 | var mergeTrafo = _getMergeTransformation(firstNode, secondNode); 32 | if (mergeTrafo) { 33 | return mergeTrafo.call(this, tx, containerId, firstComp, secondComp); 34 | } 35 | }; 36 | 37 | var _getMergeTransformation = function(node, otherNode) { 38 | // TODO: we want to introduce a way to provide custom merge behavior 39 | var trafo = null; 40 | // if (merge[node.type] && merge[node.type][otherNode.type]) { 41 | // behavior = merge[node.type][otherNode.type]; 42 | // } 43 | // special convenience to define behaviors when text nodes are involved 44 | // E.g., you might want to define how to merge a text node into a figure 45 | // else 46 | if (node.isInstanceOf('text') && otherNode.isInstanceOf('text')) { 47 | trafo = _mergeTextNodes; 48 | } 49 | // else if (node.isInstanceOf('text') && merge['text']) { 50 | // behavior = merge['text'][otherNode.type]; 51 | // } else if (otherNode.isInstanceOf('text') && merge[node.type]) { 52 | // behavior = merge[node.type]['text']; 53 | // } 54 | if (!trafo) { 55 | console.info("No merge behavior defined for %s <- %s", node.type, otherNode.type); 56 | } 57 | return trafo; 58 | }; 59 | 60 | var _mergeTextNodes = function(tx, containerId, firstComp, secondComp) { 61 | var firstPath = firstComp.path; 62 | var firstText = tx.get(firstPath); 63 | var firstLength = firstText.length; 64 | var secondPath = secondComp.path; 65 | var secondText = tx.get(secondPath); 66 | var container = tx.get(containerId); 67 | var selection; 68 | if (firstLength === 0) { 69 | // hide the second node 70 | container.hide(firstPath[0]); 71 | // delete the second node 72 | tx.delete(firstPath[0]); 73 | // set the selection to the end of the first component 74 | selection = tx.createSelection({ 75 | type: 'property', 76 | path: secondPath, 77 | startOffset: 0 78 | }); 79 | } else { 80 | // append the second text 81 | tx.update(firstPath, { insert: { offset: firstLength, value: secondText } }); 82 | // transfer annotations 83 | Annotations.transferAnnotations(tx, secondPath, 0, firstPath, firstLength); 84 | // hide the second node 85 | container.hide(secondPath[0]); 86 | // delete the second node 87 | tx.delete(secondPath[0]); 88 | // set the selection to the end of the first component 89 | selection = tx.createSelection({ 90 | type: 'property', 91 | path: firstPath, 92 | startOffset: firstLength 93 | }); 94 | } 95 | return { selection: selection }; 96 | }; 97 | 98 | module.exports = merge; 99 | -------------------------------------------------------------------------------- /document/transformations/switch_text_type.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('../../basics/helpers'); 4 | var Annotations = require('../annotation_updates'); 5 | var deleteNode = require('./delete_node'); 6 | 7 | // TODO: needs to be overhauled 8 | // should work without a given container 9 | 10 | function switchTextType(tx, args) { 11 | var selection = args.selection; 12 | if (!selection.isPropertySelection()) { 13 | console.error("Selection must be a PropertySelection."); 14 | return; 15 | } 16 | 17 | var nodeId = selection.getPath()[0]; 18 | var data = args.data; 19 | var node = tx.get(nodeId); 20 | var path = selection.path; 21 | 22 | if (!(node.isInstanceOf('text'))) { 23 | console.warn('Trying to use switchTextType on a non text node. Skipping.'); 24 | return; 25 | } 26 | 27 | // create a new node 28 | var newNode = _.extend({ 29 | id: _.uuid(data.type), 30 | type: data.type, 31 | content: node.content 32 | }, data); 33 | 34 | var newPath = [newNode.id, 'content']; 35 | var created = tx.create(newNode); 36 | 37 | Annotations.transferAnnotations(tx, path, 0, newPath, 0); 38 | 39 | // TODO: should work without a given container 40 | // _.each(tx.getContainers(), function(container) { 41 | // pos = container.getPosition(nodeId); 42 | // .... 43 | // }); 44 | 45 | var container = tx.get(args.containerId); 46 | var pos = container.getPosition(nodeId); 47 | if (pos >= 0) { 48 | container.hide(nodeId); 49 | container.show(newNode.id, pos); 50 | } 51 | 52 | deleteNode(tx, { nodeId: node.id }); 53 | 54 | return { 55 | selection: tx.createSelection({ 56 | type: 'property', 57 | path: newPath, 58 | startOffset: selection.startOffset, 59 | endOffset: selection.endOffset 60 | }) 61 | }; 62 | } 63 | 64 | module.exports = switchTextType; 65 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var glob = require('glob'); 3 | var gulp = require('gulp'); 4 | var source = require('vinyl-source-stream'); 5 | var buffer = require('vinyl-buffer'); 6 | var gutil = require('gulp-util'); 7 | var argv = require('yargs').argv; 8 | var gulpif = require('gulp-if'); 9 | var rename = require('gulp-rename'); 10 | var jshint = require('gulp-jshint'); 11 | var yuidoc = require('gulp-yuidoc'); 12 | var browserify = require('browserify'); 13 | var uglify = require('gulp-uglify'); 14 | var sourcemaps = require('gulp-sourcemaps'); 15 | // var qunit = require('node-qunit-phantomjs'); 16 | var qunit = require('gulp-qunit'); 17 | 18 | gulp.task('doc', function() { 19 | return gulp.src(["index.js", "./src/**/*.js"]) 20 | .pipe(yuidoc.parser()) 21 | .pipe(yuidoc.reporter()) 22 | .pipe(yuidoc.generator()) 23 | .pipe(gulp.dest('./doc/api')); 24 | }); 25 | 26 | gulp.task('lint', function() { 27 | return gulp.src('./src/**/*.js') 28 | .pipe(jshint()) 29 | .pipe(jshint.reporter('default')); 30 | }); 31 | 32 | gulp.task('build', ['lint'], function() { 33 | return browserify({ 34 | entries: './browser.js', 35 | debug: true 36 | }).bundle() 37 | .pipe(source('substance.js')) 38 | .pipe(buffer()) 39 | .pipe(sourcemaps.init({loadMaps: true})) 40 | .pipe(gulpif(argv.production, uglify())) 41 | .pipe(gulpif(argv.production, rename({suffix: '.min'}))) 42 | .on('error', gutil.log) 43 | .pipe(sourcemaps.write('./')) 44 | .pipe(gulp.dest('./dist')); 45 | }); 46 | 47 | gulp.task('build-test', function() { 48 | return glob("test/**/*.test.js", {}, function (err, testfiles) { 49 | browserify({ debug: true }) 50 | .add(testfiles.map(function(file) { 51 | return path.join(__dirname, file); 52 | })) 53 | .bundle() 54 | .pipe(source('test.js')) 55 | .pipe(buffer()) 56 | .pipe(sourcemaps.init({loadMaps: true})) 57 | .on('error', gutil.log) 58 | .pipe(sourcemaps.write('./')) 59 | .pipe(gulp.dest('./test/tmp')); 60 | }); 61 | }); 62 | 63 | gulp.task('test', ['build-test'], function() { 64 | return gulp.src('./test/index.html') 65 | .pipe(qunit()); 66 | }); 67 | 68 | gulp.task('default', ['build']); 69 | -------------------------------------------------------------------------------- /helpers.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./basics/helpers'); 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | var Substance = require('./basics'); 3 | 4 | Substance.Data = require('./data'); 5 | Substance.Document = require('./document'); 6 | Substance.Operator = require('./operator'); 7 | Substance.Surface = require('./surface'); 8 | Substance.Component = require('./ui/component'); 9 | 10 | Substance._ = require('./basics/helpers'); 11 | 12 | module.exports = Substance; 13 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Sat May 09 2015 01:51:50 GMT+0200 (W. Europe Summer Time) 3 | module.exports = function(config) { 4 | config.set({ 5 | basePath: '', 6 | frameworks: ['qunit', 'commonjs', 'jquery-2.1.0'], 7 | plugins: [ 8 | 'karma-jquery', 9 | 'karma-qunit', 10 | 'karma-chrome-launcher', 11 | 'karma-commonjs', 12 | 'karma-coverage' 13 | ], 14 | files: [ 15 | 'index.js', 16 | 'helpers.js', 17 | 'document.js', 18 | {pattern: 'test/public/jquery.js'}, 19 | {pattern: 'src/**/*.js'}, 20 | {pattern: 'node_modules/lodash/**/*.js'}, 21 | {pattern: 'test/fixtures/*.js'}, 22 | {pattern: 'test/test_article/*.js'}, 23 | {pattern: 'test/unit/*.js'}, 24 | {pattern: 'test/unit/**/*.test.js'} 25 | ], 26 | exclude: [ 27 | ], 28 | preprocessors: { 29 | "*.js": ["commonjs"], 30 | "src/**/*.js": ["commonjs"], 31 | "test/**/*.js": ["commonjs"], 32 | "node_modules/lodash/**/*.js": ["commonjs"], 33 | // compute test coverage only for the real modules 34 | "src/!(basics)/**/!(index).js": ["coverage"], 35 | }, 36 | reporters: ['progress', 'coverage'], 37 | coverageReporter: { 38 | type : 'html', 39 | dir : 'coverage/' 40 | }, 41 | port: 9876, 42 | colors: true, 43 | logLevel: config.LOG_INFO, 44 | autoWatch: false, 45 | browsers: ['Chrome'], 46 | singleRun: true 47 | }); 48 | }; 49 | -------------------------------------------------------------------------------- /operator/conflict.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function Conflict(a, b) { 4 | Error.call(this, "Conflict: " + JSON.stringify(a) +" vs " + JSON.stringify(b)); 5 | this.a = a; 6 | this.b = b; 7 | } 8 | Conflict.prototype = Error.prototype; 9 | 10 | module.exports = Conflict; 11 | -------------------------------------------------------------------------------- /operator/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | Operation: require('./operation'), 5 | TextOperation: require('./text_operation'), 6 | ArrayOperation: require('./array_operation'), 7 | ObjectOperation: require('./object_operation') 8 | }; 9 | -------------------------------------------------------------------------------- /operator/operation.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Substance = require('../basics'); 4 | 5 | function Operation() { 6 | } 7 | 8 | Operation.Prototype = function() { 9 | 10 | this.isOperation = true; 11 | 12 | }; 13 | 14 | Substance.initClass(Operation); 15 | 16 | module.exports = Operation; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "substance", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "gulp build-test && gulp test", 8 | "karma": "./node_modules/.bin/karma start" 9 | }, 10 | "author": "", 11 | "license": "MIT", 12 | "dependencies": { 13 | "lodash": "3.3.1" 14 | }, 15 | "devDependencies": { 16 | "browserify": "^10.1.3", 17 | "express": "^4.12.3", 18 | "glob": "^5.0.5", 19 | "gulp": "^3.8.11", 20 | "gulp-if": "^1.2.5", 21 | "gulp-jshint": "^1.10.0", 22 | "gulp-qunit": "^1.2.1", 23 | "gulp-rename": "^1.2.2", 24 | "gulp-sourcemaps": "^1.5.2", 25 | "gulp-uglify": "^1.2.0", 26 | "gulp-util": "^3.0.4", 27 | "gulp-yuidoc": "^0.1.2", 28 | "karma": "^0.12.36", 29 | "karma-chrome-launcher": "^0.1.12", 30 | "karma-commonjs": "oliver----/karma-commonjs#patched", 31 | "karma-coverage": "^0.4.2", 32 | "karma-jquery": "^0.1.0", 33 | "karma-qunit": "^0.1.4", 34 | "node-qunit-phantomjs": "^1.2.1", 35 | "qunitjs": "^1.18.0", 36 | "vinyl-buffer": "^1.0.0", 37 | "vinyl-source-stream": "^1.1.0", 38 | "yargs": "^3.8.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var path = require('path'); 3 | var glob = require('glob'); 4 | var browserify = require('browserify'); 5 | var PORT = process.env.PORT || 4201; 6 | var app = express(); 7 | app.get('/test/tmp/test.js', function (req, res, next) { 8 | glob("test/**/*.test.js", {}, function (er, testfiles) { 9 | if (er || !testfiles || testfiles.length === 0) { 10 | console.error('No tests found.'); 11 | res.send('500'); 12 | } else { 13 | console.log('Found test files:', testfiles); 14 | browserify({ debug: true }) 15 | .add(testfiles.map(function(file) { 16 | return path.join(__dirname, file); 17 | })) 18 | .bundle() 19 | .on('error', function(err){ 20 | console.error(err.message); 21 | res.status(500).send('console.log("'+err.message+'");'); 22 | next(); 23 | }) 24 | .pipe(res); 25 | } 26 | }); 27 | }); 28 | app.use(express.static(__dirname)); 29 | app.listen(PORT); 30 | console.log('Server is listening on %s', PORT); 31 | console.log('To run the test suite go to https://localhost:%s/test', PORT); 32 | -------------------------------------------------------------------------------- /surface/annotation_view.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var NodeView = require('./node_view'); 4 | 5 | var AnnotationView = NodeView.extend({ 6 | name: "annotation", 7 | tagName: 'span', 8 | 9 | getClassNames: function() { 10 | var classNames = this.node.getClassNames(); 11 | if (this.props.classNames) { 12 | classNames += " " + this.props.classNames.join(' '); 13 | } 14 | return classNames.replace(/_/g, '-'); 15 | } 16 | }); 17 | 18 | module.exports = AnnotationView; 19 | -------------------------------------------------------------------------------- /surface/container_editor.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('../basics/helpers'); 4 | var OO = require('../basics/oo'); 5 | var Document = require('../document'); 6 | var FormEditor = require('./form_editor'); 7 | var Annotations = Document.AnnotationUpdates; 8 | var Transformations = Document.Transformations; 9 | 10 | function ContainerEditor(containerId) { 11 | if (!_.isString(containerId)) throw new Error("Illegal argument: Expecting container id."); 12 | FormEditor.call(this); 13 | this.containerId = containerId; 14 | } 15 | 16 | ContainerEditor.Prototype = function() { 17 | 18 | this.isContainerEditor = function() { 19 | return true; 20 | }; 21 | 22 | this.getContainerId = function() { 23 | return this.containerId; 24 | }; 25 | 26 | /** 27 | * Performs a `deleteSelection` tr 28 | */ 29 | this.delete = function(tx, args) { 30 | args.containerId = this.containerId; 31 | return Transformations.deleteSelection(tx, args); 32 | }; 33 | 34 | this.break = function(tx, args) { 35 | args.containerId = this.containerId; 36 | if (args.selection.isPropertySelection() || args.selection.isContainerSelection()) { 37 | return Transformations.breakNode(tx, args); 38 | } 39 | }; 40 | 41 | this.insertNode = function(tx, args) { 42 | args.containerId = this.containerId; 43 | if (args.selection.isPropertySelection() || args.selection.isContainerSelection()) { 44 | return Transformations.insertNode(tx, args); 45 | } 46 | }; 47 | 48 | this.switchType = function(tx, args) { 49 | args.containerId = this.containerId; 50 | if (args.selection.isPropertySelection()) { 51 | return Transformations.switchTextType(tx, args); 52 | } 53 | }; 54 | 55 | this.selectAll = function(doc) { 56 | var container = doc.get(this.containerId); 57 | var first = container.getFirstComponent(); 58 | var last = container.getLastComponent(); 59 | var lastText = doc.get(last.path); 60 | return doc.createSelection({ 61 | type: 'container', 62 | containerId: this.containerId, 63 | startPath: first.path, 64 | startOffset: 0, 65 | endPath: last.path, 66 | endOffset: lastText.length 67 | }); 68 | }; 69 | 70 | this.paste = function(tx, args) { 71 | args.containerId = this.containerId; 72 | if (args.selection.isPropertySelection() || args.selection.isContainerSelection()) { 73 | return Transformations.paste(tx, args); 74 | } 75 | }; 76 | 77 | }; 78 | 79 | OO.inherit(ContainerEditor, FormEditor); 80 | 81 | module.exports = ContainerEditor; 82 | -------------------------------------------------------------------------------- /surface/form_editor.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Substance = require('../basics'); 4 | var Document = require('../document'); 5 | var Selection = Document.Selection; 6 | var Annotations = Document.AnnotationUpdates; 7 | var Transformations = Document.Transformations; 8 | 9 | function FormEditor() {} 10 | 11 | FormEditor.Prototype = function() { 12 | 13 | this.isContainerEditor = function() { 14 | return false; 15 | }; 16 | 17 | // Selects the current property. 18 | this.selectAll = function(doc, selection) { 19 | var sel = selection; 20 | if (sel.isNull()) return; 21 | if (sel.isPropertySelection()) { 22 | var path = sel.start.path; 23 | var text = doc.get(path); 24 | return doc.createSelection({ 25 | type: 'property', 26 | path: path, 27 | startOffset: 0, 28 | endOffset: text.length 29 | }); 30 | } 31 | }; 32 | 33 | this.insertText = function(tx, args) { 34 | if (args.selection.isPropertySelection() || args.selection.isContainerSelection()) { 35 | return Transformations.insertText(tx, args); 36 | } 37 | }; 38 | 39 | // implements backspace and delete 40 | this.delete = function(tx, args) { 41 | return Transformations.deleteSelection(tx, args); 42 | }; 43 | 44 | // no breaking 45 | this.break = function(tx, args) { 46 | return this.softBreak(tx, args); 47 | }; 48 | 49 | this.softBreak = function(tx, args) { 50 | args.text = "\n"; 51 | return this.insertText(tx, args); 52 | }; 53 | 54 | // create a document instance containing only the selected content 55 | this.copy = function(doc, selection) { 56 | var result = Transformations.copySelection(doc, { selection: selection }); 57 | return result.doc; 58 | }; 59 | 60 | this.paste = function(tx, args) { 61 | // TODO: for now only plain text is inserted 62 | // We could do some stitching however, preserving the annotations 63 | // received in the document 64 | if (args.text) { 65 | return this.insertText(tx, args); 66 | } 67 | }; 68 | 69 | }; 70 | 71 | Substance.initClass(FormEditor); 72 | 73 | module.exports = FormEditor; 74 | -------------------------------------------------------------------------------- /surface/index.js: -------------------------------------------------------------------------------- 1 | 2 | var Surface = require('./surface'); 3 | Surface.SurfaceManager = require('./surface_manager'); 4 | Surface.SurfaceSelection = require('./surface_selection'); 5 | 6 | Surface.FormEditor = require('./form_editor'); 7 | Surface.ContainerEditor = require('./container_editor'); 8 | Surface.Clipboard = require('./clipboard'); 9 | 10 | Surface.NodeView = require('./node_view'); 11 | Surface.AnnotationView = require('./annotation_view'); 12 | Surface.TextProperty = require('./text_property'); 13 | 14 | Surface.Tool = require('./tool'); 15 | Surface.AnnotationTool = require('./annotation_tool'); 16 | Surface.SwitchTypeTool = require('./switch_type_tool'); 17 | Surface.ToolRegistry = require('./tool_registry'); 18 | Surface.Panel = require('./panel'); 19 | 20 | Surface.Tools = require('./tools'); 21 | 22 | module.exports = Surface; 23 | -------------------------------------------------------------------------------- /surface/node_view.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Substance = require('../basics'); 4 | 5 | function NodeView(props) { 6 | this.props = props; 7 | this.doc = props.doc; 8 | this.node = props.node; 9 | } 10 | 11 | NodeView.Prototype = function() { 12 | 13 | this.tagName = 'div'; 14 | 15 | this.createElement = function() { 16 | var element = document.createElement(this.getTagName()); 17 | var classNames = this.getClassNames(); 18 | $(element).addClass(classNames); 19 | element.dataset.id = this.node.id; 20 | return element; 21 | }; 22 | 23 | this.getTagName = function() { 24 | return this.node.constructor.static.tagName || this.tagName; 25 | }; 26 | 27 | this.getClassNames = function() { 28 | return []; 29 | }; 30 | 31 | this.render = function() { 32 | var element = this.createElement(); 33 | var children = this.props.children; 34 | if (children) { 35 | for (var i = 0; i < children.length; i++) { 36 | var child = children[i]; 37 | if (Substance.isString(child)) { 38 | element.appendChild(document.createTextNode(child)); 39 | } else if (child instanceof NodeView) { 40 | var el = child.render(); 41 | element.appendChild(el); 42 | } else if (child instanceof window.Node) { 43 | element.appendChild(child); 44 | } 45 | } 46 | } 47 | return element; 48 | }; 49 | 50 | }; 51 | 52 | Substance.initClass(NodeView); 53 | 54 | module.exports = NodeView; 55 | -------------------------------------------------------------------------------- /surface/panel.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Substance = require("../basics"); 4 | 5 | // Mixin with helpers to implement a scrollable panel 6 | function Panel() { 7 | 8 | } 9 | 10 | Panel.Prototype = function() { 11 | 12 | // Get the current coordinates of the first element in the 13 | // set of matched elements, relative to the offset parent 14 | // Please be aware that it looks up until it finds a parent that has 15 | // position: relative|absolute set. So for now never set relative somewhere in your panel 16 | this.getPanelOffsetForElement = function(el) { 17 | var offsetTop = $(el).position().top; 18 | return offsetTop; 19 | }; 20 | 21 | this.scrollToNode = function(nodeId) { 22 | // var n = this.findNodeView(nodeId); 23 | // TODO make this generic 24 | var panelContentEl = this.getScrollableContainer(); 25 | 26 | // Node we want to scroll to 27 | var targetNode = $(panelContentEl).find("*[data-id="+nodeId+"]")[0]; 28 | 29 | if (targetNode) { 30 | $(panelContentEl).scrollTop(this.getPanelOffsetForElement(targetNode)); 31 | } else { 32 | console.warn(nodeId, 'not found in scrollable container'); 33 | } 34 | }; 35 | 36 | }; 37 | 38 | Substance.initClass(Panel); 39 | module.exports = Panel; 40 | 41 | 42 | -------------------------------------------------------------------------------- /surface/surface_manager.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var OO = require('../basics/oo'); 4 | var _ = require('../basics/helpers'); 5 | var Surface = require('./surface'); 6 | var EventEmitter = require('../basics/event_emitter'); 7 | 8 | var SurfaceManager = function(doc) { 9 | EventEmitter.call(this); 10 | this.doc = doc; 11 | this.surfaces = {}; 12 | this.focusedSurface = null; 13 | this.stack = []; 14 | doc.connect(this, { 'document:changed': this.onDocumentChange }, { 15 | //lower priority so that everyting is up2date 16 | //when we render the selection 17 | priority: -1 18 | }); 19 | }; 20 | 21 | SurfaceManager.Prototype = function() { 22 | 23 | this.dispose = function() { 24 | this.doc.disconnect(this); 25 | this.surfaces = {}; 26 | }; 27 | 28 | this.createSurface = function(editor, options) { 29 | return new Surface(this, editor, options); 30 | }; 31 | 32 | this.registerSurface = function(surface) { 33 | surface.connect(this, { 34 | 'selection:changed': this.onSelectionChanged 35 | }); 36 | this.surfaces[surface.getName()] = surface; 37 | }; 38 | 39 | this.unregisterSurface = function(surface) { 40 | surface.disconnect(this); 41 | delete this.surfaces[surface.getName()]; 42 | if (surface && this.focusedSurface === surface) { 43 | this.focusedSurface = null; 44 | } 45 | }; 46 | 47 | this.hasSurfaces = function() { 48 | return Object.keys(this.surfaces).length > 0; 49 | }; 50 | 51 | this.didFocus = function(surface) { 52 | if (this.focusedSurface && surface !== this.focusedSurface) { 53 | this.focusedSurface.setFocused(false); 54 | } 55 | this.focusedSurface = surface; 56 | }; 57 | 58 | this.getFocusedSurface = function() { 59 | return this.focusedSurface; 60 | }; 61 | 62 | this.onDocumentChange = function(change, info) { 63 | if (info.replay) { 64 | var selection = change.after.selection; 65 | var surfaceId = change.after.surfaceId; 66 | if (surfaceId) { 67 | var surface = this.surfaces[surfaceId]; 68 | if (surface) { 69 | if (this.focusedSurface !== surface) { 70 | this.didFocus(surface); 71 | } 72 | surface.setSelection(selection); 73 | } else { 74 | console.warn('No surface with name', surfaceId); 75 | } 76 | } 77 | } 78 | }; 79 | 80 | this.onSelectionChanged = function(sel, surface) { 81 | this.emit('selection:changed', sel, surface); 82 | }; 83 | 84 | this.pushState = function() { 85 | var state = { 86 | surface: this.focusedSurface, 87 | selection: null 88 | } 89 | if (this.focusedSurface) { 90 | state.selection = this.focusedSurface.getSelection(); 91 | } 92 | this.focusedSurface = null; 93 | this.stack.push(state); 94 | }; 95 | 96 | this.popState = function() { 97 | var state = this.stack.pop(); 98 | if (state && state.surface) { 99 | state.surface.setFocused(true); 100 | state.surface.setSelection(state.selection); 101 | } 102 | }; 103 | 104 | }; 105 | 106 | OO.inherit(SurfaceManager, EventEmitter); 107 | 108 | module.exports = SurfaceManager; 109 | -------------------------------------------------------------------------------- /surface/switch_type_tool.js: -------------------------------------------------------------------------------- 1 | var Substance = require("../basics"); 2 | var Tool = require('./tool'); 3 | 4 | function SwitchTypeTool() { 5 | Tool.call(this); 6 | } 7 | 8 | SwitchTypeTool.Prototype = function() { 9 | 10 | // Provides the type of the associated annotation node. 11 | // The default implementation uses the Tool's static name. 12 | // Override this method to customize. 13 | this.getNodeType = function() { 14 | if (this.constructor.static.name) { 15 | return this.constructor.static.name; 16 | } else { 17 | throw new Error('Contract: SwitchTypeTool.static.name should be associated to a document annotation type.'); 18 | } 19 | }; 20 | 21 | this.getData = function() { 22 | return {}; 23 | }; 24 | 25 | this.matchNode = function(node) { 26 | return (node.type === this.getNodeType()); 27 | }; 28 | 29 | this.update = function(surface, sel) { 30 | this.surface = surface; 31 | if (!surface.isEnabled() || sel.isNull() || sel.isContainerSelection() || 32 | !surface.getEditor().isContainerEditor()) { 33 | return this.setDisabled(); 34 | } 35 | var container = surface.getEditor().getContainer(); 36 | var node = container.getNodeForComponentPath(sel.start.path); 37 | if (this.matchNode(node)) { 38 | return this.setToolState({ 39 | enabled: true, 40 | selected: true 41 | }); 42 | } else if (node.isInstanceOf('text')) { 43 | return this.setToolState({ 44 | enabled: true, 45 | selected: true, 46 | sel: sel, 47 | node: node, 48 | mode: "switch" 49 | }); 50 | } 51 | }; 52 | 53 | this.performAction = function() { 54 | var state = this.getToolState(); 55 | if (state.mode === "switch") { 56 | this.surface.getEditor().switchType(state.sel, this.getNodeType(), this.getData()); 57 | } 58 | }; 59 | }; 60 | 61 | Substance.inherit(SwitchTypeTool, Tool); 62 | 63 | module.exports = SwitchTypeTool; 64 | -------------------------------------------------------------------------------- /surface/tool.js: -------------------------------------------------------------------------------- 1 | var Substance = require("../basics"); 2 | 3 | function Tool(context) { 4 | Substance.EventEmitter.call(this); 5 | 6 | this.context = context; 7 | 8 | this.state = { 9 | // we disable tools by default 10 | disabled: true, 11 | // if the tool is turned on / toggled on 12 | active: false 13 | }; 14 | } 15 | 16 | Tool.Prototype = function() { 17 | 18 | this.needsEnabledSurface = true; 19 | 20 | this.getName = function() { 21 | return this.constructor.static.name; 22 | }; 23 | 24 | this.getSurface = function() { 25 | return this.surface; 26 | }; 27 | 28 | this.getDocument = function() { 29 | var surface = this.getSurface(); 30 | if (surface) { 31 | return surface.getDocument(); 32 | } 33 | }; 34 | 35 | this.getContainer = function() { 36 | var surface = this.getSurface(); 37 | if (surface) { 38 | return surface.getContainer(); 39 | } 40 | }; 41 | 42 | this.setToolState = function(newState) { 43 | var oldState = this.state; 44 | this.state = newState; 45 | this.emit('toolstate:changed', newState, this, oldState); 46 | }; 47 | 48 | this.getToolState = function() { 49 | return this.state; 50 | }; 51 | 52 | this.isEnabled = function() { 53 | return !this.state.disabled; 54 | }; 55 | 56 | this.isDisabled = function() { 57 | return this.state.disabled; 58 | }; 59 | 60 | this.setEnabled = function() { 61 | this.setToolState({ 62 | disabled: false, 63 | active: false 64 | }); 65 | }; 66 | 67 | this.setDisabled = function() { 68 | this.setToolState({ 69 | disabled: true, 70 | active: false 71 | }); 72 | }; 73 | 74 | this.disableTool = function() { 75 | console.error('DEPRECATED: use tool.setDisabled()'); 76 | this.setDisabled(); 77 | }; 78 | 79 | this.setSelected = function() { 80 | this.setToolState({ 81 | disabled: false, 82 | active: true 83 | }); 84 | }; 85 | 86 | /* jshint unused:false */ 87 | this.update = function(surface, sel) { 88 | this.surface = surface; 89 | if (this.needsEnabledSurface && !surface.isEnabled()) { 90 | return this.setDisabled(); 91 | } 92 | }; 93 | 94 | //legacy TODO fixme 95 | this.updateToolState = function(sel, surface) { 96 | return this.update(surface, sel); 97 | }; 98 | }; 99 | 100 | Substance.inherit(Tool, Substance.EventEmitter); 101 | 102 | module.exports = Tool; 103 | -------------------------------------------------------------------------------- /surface/tool_registry.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var Substance = require('../basics'); 4 | var _ = Substance._; 5 | 6 | var ToolRegistry = function() { 7 | Substance.Registry.call(this); 8 | }; 9 | 10 | ToolRegistry.Prototype = function() { 11 | 12 | this.dispose = function() { 13 | this.each(function(tool) { 14 | if (tool.dispose) { 15 | tool.dispose(); 16 | } 17 | }); 18 | this.clear(); 19 | }; 20 | 21 | }; 22 | 23 | Substance.inherit(ToolRegistry, Substance.Registry); 24 | 25 | module.exports = ToolRegistry; 26 | -------------------------------------------------------------------------------- /surface/tools/delete_columns.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Tool = require('../tool'); 4 | 5 | var DeleteColumnsTool = Tool.extend({ 6 | 7 | name: "delete_columns", 8 | 9 | update: function(surface, sel) { 10 | this.surface = surface; // IMPORTANT! 11 | // Set disabled when not a property selection 12 | if (!surface.isEnabled() || sel.isNull() || !sel.isTableSelection()) { 13 | return this.setDisabled(); 14 | } 15 | this.setToolState({ 16 | surface: surface, 17 | sel: sel, 18 | disabled: false 19 | }); 20 | }, 21 | 22 | performAction: function(options) { 23 | this.surface.transaction(function(tx, args) { 24 | console.log('TODO: delete columns', options); 25 | return args; 26 | }); 27 | }, 28 | 29 | }); 30 | 31 | module.exports = DeleteColumnsTool; 32 | -------------------------------------------------------------------------------- /surface/tools/delete_rows.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Tool = require('../tool'); 4 | 5 | var DeleteRowsTool = Tool.extend({ 6 | 7 | name: "delete_rows", 8 | 9 | update: function(surface, sel) { 10 | this.surface = surface; // IMPORTANT! 11 | // Set disabled when not a property selection 12 | if (!surface.isEnabled() || sel.isNull() || !sel.isTableSelection()) { 13 | return this.setDisabled(); 14 | } 15 | this.setToolState({ 16 | surface: surface, 17 | sel: sel, 18 | disabled: false 19 | }); 20 | }, 21 | 22 | performAction: function(options) { 23 | this.surface.transaction(function(tx, args) { 24 | console.log('TODO: delete rows', options); 25 | return args; 26 | }); 27 | }, 28 | 29 | }); 30 | 31 | module.exports = DeleteRowsTool; 32 | -------------------------------------------------------------------------------- /surface/tools/emphasis_tool.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var AnnotationTool = require('../annotation_tool'); 4 | 5 | var EmphasisTool = AnnotationTool.extend({ 6 | name: "emphasis" 7 | }); 8 | 9 | module.exports = EmphasisTool; 10 | -------------------------------------------------------------------------------- /surface/tools/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Undo: require('./undo_tool'), 3 | Redo: require('./redo_tool'), 4 | Emphasis: require('./emphasis_tool'), 5 | Strong: require('./strong_tool'), 6 | Link: require('./link_tool'), 7 | SwitchTextType: require('./switch_text_type_tool'), 8 | InsertRows: require('./insert_rows'), 9 | DeleteRows: require('./delete_rows'), 10 | InsertColumns: require('./insert_columns'), 11 | DeleteColumns: require('./delete_columns'), 12 | }; -------------------------------------------------------------------------------- /surface/tools/insert_columns.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Tool = require('../tool'); 4 | 5 | var InsertColumnsTool = Tool.extend({ 6 | 7 | name: "insert_columns", 8 | 9 | update: function(surface, sel) { 10 | this.surface = surface; // IMPORTANT! 11 | // Set disabled when not a property selection 12 | if (!surface.isEnabled() || sel.isNull() || !sel.isTableSelection()) { 13 | return this.setDisabled(); 14 | } 15 | this.setToolState({ 16 | surface: surface, 17 | sel: sel, 18 | disabled: false 19 | }); 20 | }, 21 | 22 | performAction: function(options) { 23 | this.surface.transaction(function(tx, args) { 24 | console.log('TODO: insert columns', options); 25 | return args; 26 | }); 27 | }, 28 | 29 | }); 30 | 31 | module.exports = InsertColumnsTool; 32 | -------------------------------------------------------------------------------- /surface/tools/insert_rows.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Tool = require('../tool'); 4 | 5 | var InsertRowsTool = Tool.extend({ 6 | 7 | name: "insert_rows", 8 | 9 | update: function(surface, sel) { 10 | this.surface = surface; // IMPORTANT! 11 | // Set disabled when not a property selection 12 | if (!surface.isEnabled() || sel.isNull() || !sel.isTableSelection()) { 13 | return this.setDisabled(); 14 | } 15 | this.setToolState({ 16 | surface: surface, 17 | sel: sel, 18 | disabled: false 19 | }); 20 | }, 21 | 22 | performAction: function(options) { 23 | this.surface.transaction(function(tx, args) { 24 | console.log('TODO: insert rows', options); 25 | return args; 26 | }); 27 | }, 28 | 29 | }); 30 | 31 | module.exports = InsertRowsTool; 32 | -------------------------------------------------------------------------------- /surface/tools/link_tool.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('substance/helpers'); 4 | var AnnotationTool = require('../annotation_tool'); 5 | var LinkTool = AnnotationTool.extend({ 6 | 7 | name: "link", 8 | 9 | getAnnotationData: function() { 10 | return { 11 | url: "http://", 12 | title: "" 13 | }; 14 | }, 15 | 16 | update: function(surface, sel) { 17 | this.surface = surface; 18 | if ( !surface.isEnabled() || sel.isNull() || sel.isContainerSelection() ) { 19 | return this.setDisabled(); 20 | } 21 | var doc = this.getDocument(); 22 | var annos = doc.getAnnotationsForSelection(sel, { type: 'link' }); 23 | var oldState = this.getToolState(); 24 | var newState = { 25 | surface: surface, 26 | disabled: false, 27 | active: false, 28 | mode: null, 29 | sel: sel, 30 | annos: annos 31 | }; 32 | if (this.canCreate(annos, sel)) { 33 | newState.mode = "create"; 34 | } else if (this.canTruncate(annos, sel)) { 35 | newState.mode = "truncate"; 36 | newState.active = true; 37 | } else if (this.canExpand(annos, sel)) { 38 | newState.mode = "expand"; 39 | } else if (annos.length === 1) { 40 | newState.mode = "edit"; 41 | newState.linkId = annos[0].id; 42 | newState.active = true; 43 | // newState.showPopup = true; 44 | } else { 45 | return this.setDisabled(); 46 | } 47 | this.setToolState(newState); 48 | }, 49 | 50 | updateLink: function(linkAttrs) { 51 | var doc = this.getDocument(); 52 | var link = this.getLink(); 53 | this.surface.transaction(function(tx) { 54 | tx.set([link.id, "url"], linkAttrs.url); 55 | tx.set([link.id, "title"], linkAttrs.title); 56 | }); 57 | }, 58 | 59 | getLink: function() { 60 | return this.getDocument().get(this.state.linkId); 61 | }, 62 | 63 | performAction: function() { 64 | var state = this.getToolState(); 65 | var newState = _.extend({}, state); 66 | if (state.mode === "edit") { 67 | // TODO: is this needed? 68 | // this.emit('edit', this); 69 | newState.showPrompt = true; 70 | this.setToolState(newState); 71 | } else { 72 | AnnotationTool.prototype.performAction.call(this); 73 | } 74 | }, 75 | 76 | }); 77 | 78 | module.exports = LinkTool; 79 | -------------------------------------------------------------------------------- /surface/tools/redo_tool.js: -------------------------------------------------------------------------------- 1 | var Tool = require('../tool'); 2 | 3 | var RedoTool = Tool.extend({ 4 | 5 | name: "redo", 6 | 7 | update: function(surface) { 8 | this.surface = surface; 9 | var doc = surface.getDocument(); 10 | if (!surface.isEnabled() || doc.undone.length===0) { 11 | this.setDisabled(); 12 | } else { 13 | this.setEnabled(); 14 | } 15 | }, 16 | 17 | performAction: function() { 18 | var doc = this.getDocument(); 19 | if (this.isEnabled() && doc.undone.length>0) { 20 | doc.redo(); 21 | } 22 | } 23 | 24 | }); 25 | 26 | module.exports = RedoTool; 27 | -------------------------------------------------------------------------------- /surface/tools/strong_tool.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var AnnotationTool = require('../annotation_tool'); 4 | 5 | var StrongTool = AnnotationTool.extend({ 6 | name: "strong" 7 | }); 8 | 9 | module.exports = StrongTool; 10 | -------------------------------------------------------------------------------- /surface/tools/switch_text_type_tool.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Tool = require('../tool'); 4 | 5 | var TEXT_NODE_TYPES = ["paragraph", "heading"]; 6 | 7 | var TEXT_TYPES = { 8 | "paragraph": {label: 'Paragraph', data: {type: "paragraph"}}, 9 | "heading1": {label: 'Heading 1', data: {type: "heading", level: 1}}, 10 | "heading2": {label: 'Heading 2', data: {type: "heading", level: 2}}, 11 | "heading3": {label: 'Heading 3', data: {type: "heading", level: 3}} 12 | }; 13 | 14 | var TextTool = Tool.extend({ 15 | 16 | name: "text", 17 | 18 | update: function(surface, sel) { 19 | this.surface = surface; // IMPORTANT! 20 | // Set disabled when not a property selection 21 | if (!surface.isEnabled() || sel.isNull()) { 22 | return this.setDisabled(); 23 | } 24 | if (sel.isTableSelection()) { 25 | return this.setToolState({ 26 | disabled: true, 27 | currentContext: 'table' 28 | }); 29 | } else if (sel.isContainerSelection()) { 30 | return this.setToolState({ 31 | disabled: true, 32 | currentContext: 'container' 33 | }); 34 | } 35 | 36 | var doc = this.getDocument(); 37 | var path = sel.getPath(); 38 | var node = doc.get(path[0]); 39 | var textType = this.getTextType(node); 40 | var parentNode = node.getRoot(); 41 | var currentContext = this.getContext(parentNode, path); 42 | 43 | var newState = { 44 | surface: surface, 45 | sel: sel, 46 | disabled: !textType, 47 | currentTextType: textType, 48 | currentContext: currentContext, 49 | }; 50 | 51 | this.setToolState(newState); 52 | }, 53 | 54 | getAvailableTextTypes: function() { 55 | return TEXT_TYPES; 56 | }, 57 | 58 | isTextType: function(type) { 59 | return TEXT_NODE_TYPES.indexOf(type) >= 0; 60 | }, 61 | 62 | // Get text type for a given node 63 | getTextType: function(node) { 64 | if (this.isTextType(node.type)) { 65 | var textType = node.type; 66 | if (textType === "heading") { 67 | textType += node.level; 68 | } 69 | return textType; 70 | } 71 | }, 72 | 73 | switchTextType: function(textTypeName) { 74 | var state = this.getToolState(); 75 | if (this.isDisabled()) return; 76 | 77 | var textType = TEXT_TYPES[textTypeName]; 78 | var surface = state.surface; 79 | var editor = surface.getEditor(); 80 | 81 | surface.transaction(function(tx, args) { 82 | args.data = textType.data; 83 | return editor.switchType(tx, args); 84 | }); 85 | }, 86 | 87 | getContext: function(parentNode, path) { 88 | if (parentNode.id === path[0]) { 89 | return path[1]; 90 | } else { 91 | return parentNode.type; 92 | } 93 | }, 94 | 95 | }); 96 | 97 | module.exports = TextTool; 98 | -------------------------------------------------------------------------------- /surface/tools/undo_tool.js: -------------------------------------------------------------------------------- 1 | var Tool = require('../tool'); 2 | 3 | var UndoTool = Tool.extend({ 4 | 5 | name: "undo", 6 | 7 | update: function(surface) { 8 | this.surface = surface; 9 | var doc = surface.getDocument(); 10 | if (!surface.isEnabled() || doc.done.length===0) { 11 | this.setDisabled(); 12 | } else { 13 | this.setEnabled(); 14 | } 15 | }, 16 | 17 | performAction: function() { 18 | var doc = this.getDocument(); 19 | if (this.isEnabled() && doc.done.length>0) { 20 | doc.undo(); 21 | } 22 | } 23 | 24 | }); 25 | 26 | module.exports = UndoTool; -------------------------------------------------------------------------------- /test/fixtures/container_anno_sample.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Article = require('../test_article'); 4 | 5 | module.exports = function() { 6 | var article = new Article(); 7 | article.set(['meta', 'title'], 'Sample1'); 8 | article.create({ 9 | type: 'paragraph', 10 | id: 'p1', 11 | content: '0123456789' 12 | }); 13 | article.create({ 14 | type: 'paragraph', 15 | id: 'p2', 16 | content: '0123456789' 17 | }); 18 | article.create({ 19 | type: 'paragraph', 20 | id: 'p3', 21 | content: '0123456789' 22 | }); 23 | article.create({ 24 | type: 'paragraph', 25 | id: 'p4', 26 | content: '0123456789' 27 | }); 28 | article.create({ 29 | type: 'test-container-anno', 30 | id: 'a1', 31 | container: 'main', 32 | startPath: ['p1', 'content'], 33 | startOffset: 5, 34 | endPath: ['p3', 'content'], 35 | endOffset: 4, 36 | }); 37 | var main = article.get('main'); 38 | main.show('p1'); 39 | main.show('p2'); 40 | main.show('p3'); 41 | main.show('p4'); 42 | article.documentDidLoad(); 43 | article.FORCE_TRANSACTIONS = false; 44 | return article; 45 | }; 46 | -------------------------------------------------------------------------------- /test/fixtures/sample1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 🐰 6 | 7 | 8 | 9 |

    10 | 11 |
    12 |

    Test Article

    13 |
    14 |
    15 |
    16 |

    Section 1

    17 |

    Paragraph 1

    18 |

    Section 2

    19 |

    Paragraph 2

    20 |

    Section 2.1

    21 |

    Paragraph 3

    22 |
    23 | 24 | 25 | -------------------------------------------------------------------------------- /test/fixtures/sample1.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Article = require('../test_article'); 4 | 5 | module.exports = function sample1() { 6 | var article = new Article(); 7 | article.set(['meta', 'title'], 'Sample1'); 8 | article.create({ 9 | type: 'heading', 10 | id: 'h1', 11 | content: 'Section 1', 12 | level: 1 13 | }); 14 | article.create({ 15 | type: 'paragraph', 16 | id: 'p1', 17 | content: 'Paragraph 1' 18 | }); 19 | article.create({ 20 | type: 'heading', 21 | id: 'h2', 22 | content: 'Section 2', 23 | level: 1 24 | }); 25 | article.create({ 26 | type: 'paragraph', 27 | id: 'p2', 28 | content: 'Paragraph with annotation' 29 | }); 30 | article.create({ 31 | type: 'emphasis', 32 | id: 'em1', 33 | path: ['p2', 'content'], 34 | startOffset: 15, 35 | endOffset: 25 36 | }) 37 | article.create({ 38 | type: 'heading', 39 | id: 'h3', 40 | content: 'Section 2.2', 41 | level: 2 42 | }); 43 | article.create({ 44 | type: 'paragraph', 45 | id: 'p3', 46 | content: 'Paragraph 3' 47 | }); 48 | article.create({ 49 | type: "test-node", 50 | id: "test", 51 | boolVal: true, 52 | stringVal: "Test", 53 | arrayVal: [1, 2, 3, 4], 54 | objectVal: { "a": 1, "b": 2 } 55 | }); 56 | var main = article.get('main'); 57 | main.show('h1'); 58 | main.show('p1'); 59 | main.show('h2'); 60 | main.show('p2'); 61 | main.show('h3'); 62 | main.show('p3'); 63 | article.documentDidLoad(); 64 | article.FORCE_TRANSACTIONS = false; 65 | return article; 66 | }; 67 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Substance Tests 5 | 6 | 7 | 8 | 9 | 10 |
    11 |
    12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /test/test_article/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var TestArticle = require('./test_article'); 4 | 5 | module.exports = TestArticle; 6 | -------------------------------------------------------------------------------- /test/test_article/test_article.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var OO = require('../../basics/oo'); 4 | var Document = require('../../document'); 5 | var schema = require('./test_schema'); 6 | 7 | var TestHtmlImporter = require('./test_html_importer'); 8 | //var TestHtmlExporter = require('./test_html_exporter'); 9 | 10 | var TestArticle = function() { 11 | TestArticle.super.call(this, schema); 12 | }; 13 | 14 | TestArticle.Prototype = function() { 15 | 16 | this.initialize = function() { 17 | this.super.initialize.apply(this, arguments); 18 | this.create({ 19 | type: "meta", 20 | id: "meta", 21 | title: 'Untitled' 22 | }); 23 | this.create({ 24 | type: "container", 25 | id: "main", 26 | nodes: [] 27 | }); 28 | }; 29 | 30 | this.toHtml = function() { 31 | return new TestHtmlImporter().convert(this); 32 | }; 33 | 34 | this.propertyToHtml = function(path) { 35 | // return new TestHtmlExporter().convertProperty(this, path); 36 | }; 37 | 38 | this.getDocumentMeta = function() { 39 | return this.get('meta'); 40 | }; 41 | }; 42 | 43 | OO.inherit(TestArticle, Document); 44 | 45 | TestArticle.fromHtml = function(html) { 46 | var $root; 47 | if (typeof window === "undefined") { 48 | $root = $(html); 49 | } else { 50 | var parser = new window.DOMParser(); 51 | var htmlDoc = parser.parseFromString(html, "text/html"); 52 | $root = $(htmlDoc); 53 | } 54 | var doc = new TestArticle(); 55 | new TestHtmlImporter().convert($root, doc); 56 | doc.documentDidLoad(); 57 | return doc; 58 | }; 59 | 60 | module.exports = TestArticle; 61 | -------------------------------------------------------------------------------- /test/test_article/test_article_meta.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Document = require('../../document'); 4 | 5 | var MetaNode = Document.Node.extend({ 6 | name: "meta", 7 | properties: { 8 | "title": "string" 9 | } 10 | }); 11 | 12 | MetaNode.static.matchElement = function($el) { 13 | return $el.attr('typeof') === 'meta'; 14 | }; 15 | 16 | MetaNode.static.fromHtml = function($el, converter) { 17 | var id = 'meta'; 18 | var meta = { 19 | id: id, 20 | title: "" 21 | }; 22 | var $title = $el.find('[property=title]'); 23 | if ($title.length) { 24 | meta.title = converter.annotatedText($title, [id, 'title']); 25 | } else { 26 | converter.warning('MetaNode: no title found.'); 27 | } 28 | return meta; 29 | }; 30 | 31 | MetaNode.static.toHtml = function(articleMeta, converter) { 32 | var id = articleMeta.id; 33 | var $el = $('
    ') 34 | .attr('typeof', 'meta') 35 | .attr('id', id); 36 | 37 | var $title = $('

    ') 38 | .attr('property', 'title') 39 | .append(converter.annotatedText([id, 'title'])); 40 | 41 | return $el.append($title); 42 | }; 43 | 44 | module.exports = MetaNode; 45 | -------------------------------------------------------------------------------- /test/test_article/test_container_annotation.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Document = require('../../document'); 4 | 5 | var TestContainerAnnotation = Document.ContainerAnnotation.extend({ 6 | name: 'test-container-anno', 7 | }); 8 | 9 | module.exports = TestContainerAnnotation; -------------------------------------------------------------------------------- /test/test_article/test_html_importer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var OO = require('../../basics/oo'); 4 | var Document = require('../../document'); 5 | var schema = require('./test_schema'); 6 | 7 | var HtmlImporter = Document.HtmlImporter; 8 | 9 | function TestHtmlImporter() { 10 | TestHtmlImporter.super.call(this, { schema: schema }); 11 | } 12 | 13 | TestHtmlImporter.Prototype = function() { 14 | 15 | this.convert = function($rootEl, doc) { 16 | this.initialize(doc, $rootEl); 17 | 18 | var $body = $rootEl.find('body'); 19 | if(!$body.length) { 20 | throw new Error('body is mandatory'); 21 | } 22 | var $header = $body.children('header'); 23 | if (!$header.length) { 24 | throw new Error('body/header is mandatory'); 25 | } 26 | var $main = $body.children('main'); 27 | if (!$main.length) { 28 | throw new Error('body/main is mandatory'); 29 | } 30 | 31 | this.header($header); 32 | this.main($main); 33 | 34 | this.finish(); 35 | }; 36 | 37 | this.header = function($header) { 38 | var self = this; 39 | var doc = this.state.doc; 40 | $header.children().each(function() { 41 | var $child = self.$(this); 42 | self.convertElement($child); 43 | }); 44 | // TODO: should we do some QA here? 45 | var meta = doc.get('meta'); 46 | if (!meta) { 47 | console.error('Article should have `body/header/[typeof=meta]`.'); 48 | } 49 | }; 50 | 51 | this.main = function($main) { 52 | this.convertContainer($main, 'main'); 53 | }; 54 | 55 | }; 56 | 57 | OO.inherit(TestHtmlImporter, HtmlImporter); 58 | 59 | module.exports = TestHtmlImporter; -------------------------------------------------------------------------------- /test/test_article/test_node.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var DocumentNode = require('../../document/node'); 4 | 5 | var TestNode = DocumentNode.extend({ 6 | name: "test-node", 7 | properties: { 8 | boolVal: "boolean", 9 | stringVal: "string", 10 | arrayVal: ["array","string"], 11 | objectVal: "object", 12 | }, 13 | }); 14 | 15 | TestNode.static.defaultProperties = { 16 | boolVal: false, 17 | stringVal: "", 18 | arrayVal: [], 19 | objectVal: {} 20 | }; 21 | 22 | TestNode.static.components = []; 23 | 24 | // HtmlImporter 25 | 26 | TestNode.static.blockType = true; 27 | 28 | TestNode.static.matchElement = function($el) { 29 | return $el.is('div[typeof=test]'); 30 | }; 31 | 32 | TestNode.static.fromHtml = function($el, converter) { 33 | var id = converter.defaultId($el, 'test-node'); 34 | var node = { 35 | id: id 36 | }; 37 | node.boolVal = !!$el.data('boolVal'); 38 | node.stringVal = $el.data('stringVal') || ""; 39 | node.arrayVal = ($el.data('arrayVal') || "").split(/\s*,\s*/); 40 | var $script = $el.find('script'); 41 | if ($script.length) { 42 | node.objectVal = JSON.parse($script.text()); 43 | } 44 | return node; 45 | }; 46 | 47 | TestNode.static.toHtml = function(node) { 48 | var id = node.id; 49 | var $el = ('
    ') 50 | .attr('id', id) 51 | .data('boolVal', node.boolVal) 52 | .data('stringVal', node.stringVal) 53 | .data('arrayVal', node.arrayVal.join(',')) 54 | .append($('