├── index.js ├── src ├── themes │ ├── base │ │ ├── modules │ │ │ ├── toolbar.styl │ │ │ ├── paste-manager.styl │ │ │ ├── tooltip.styl │ │ │ ├── link-tooltip.styl │ │ │ ├── multi-cursor.styl │ │ │ ├── image-tooltip.styl │ │ │ └── video-tooltip.styl │ │ ├── index.coffee │ │ └── base.styl │ └── snow │ │ ├── assets │ │ ├── bold.png │ │ ├── left.png │ │ ├── link.png │ │ ├── list.png │ │ ├── bold@2x.png │ │ ├── bullet.png │ │ ├── center.png │ │ ├── color.png │ │ ├── image.png │ │ ├── italic.png │ │ ├── justify.png │ │ ├── left@2x.png │ │ ├── link@2x.png │ │ ├── list@2x.png │ │ ├── right.png │ │ ├── strike.png │ │ ├── video.png │ │ ├── bullet@2x.png │ │ ├── center@2x.png │ │ ├── color@2x.png │ │ ├── dropdown.png │ │ ├── image@2x.png │ │ ├── italic@2x.png │ │ ├── right@2x.png │ │ ├── strike@2x.png │ │ ├── underline.png │ │ ├── video@2x.png │ │ ├── active │ │ │ ├── .DS_Store │ │ │ ├── bold.png │ │ │ ├── color.png │ │ │ ├── image.png │ │ │ ├── left.png │ │ │ ├── link.png │ │ │ ├── list.png │ │ │ ├── right.png │ │ │ ├── video.png │ │ │ ├── bold@2x.png │ │ │ ├── bullet.png │ │ │ ├── center.png │ │ │ ├── indent.png │ │ │ ├── italic.png │ │ │ ├── justify.png │ │ │ ├── left@2x.png │ │ │ ├── link@2x.png │ │ │ ├── list@2x.png │ │ │ ├── strike.png │ │ │ ├── authorship.png │ │ │ ├── background.png │ │ │ ├── bullet@2x.png │ │ │ ├── center@2x.png │ │ │ ├── color@2x.png │ │ │ ├── dropdown.png │ │ │ ├── image@2x.png │ │ │ ├── indent@2x.png │ │ │ ├── italic@2x.png │ │ │ ├── justify@2x.png │ │ │ ├── right@2x.png │ │ │ ├── strike@2x.png │ │ │ ├── underline.png │ │ │ ├── video@2x.png │ │ │ ├── dropdown@2x.png │ │ │ ├── underline@2x.png │ │ │ ├── authorship@2x.png │ │ │ └── background@2x.png │ │ ├── authorship.png │ │ ├── background.png │ │ ├── dropdown@2x.png │ │ ├── justify@2x.png │ │ ├── underline@2x.png │ │ ├── authorship@2x.png │ │ ├── background@2x.png │ │ └── inactive │ │ │ ├── dropdown.png │ │ │ └── dropdown@2x.png │ │ ├── modules │ │ ├── link-tooltip.styl │ │ ├── image-tooltip.styl │ │ ├── video-tooltip.styl │ │ ├── tooltip.styl │ │ ├── multi-cursor.styl │ │ └── toolbar.styl │ │ ├── snow.styl │ │ └── index.coffee ├── index.coffee ├── lib │ ├── color-picker.coffee │ ├── range.coffee │ ├── linked-list.coffee │ └── picker.coffee ├── core │ ├── leaf.coffee │ ├── document.coffee │ ├── normalizer.coffee │ ├── format.coffee │ ├── line.coffee │ ├── selection.coffee │ └── editor.coffee └── modules │ ├── paste-manager.coffee │ ├── authorship.coffee │ ├── tooltip.coffee │ ├── image-tooltip.coffee │ ├── undo-manager.coffee │ ├── video-tooltip.coffee │ ├── multi-cursor.coffee │ ├── link-tooltip.coffee │ ├── keyboard.coffee │ └── toolbar.coffee ├── .gitignore ├── test ├── fixtures │ ├── style.css │ ├── unit.html │ ├── memory.html │ └── e2e.html ├── helpers │ ├── coverage.coffee │ ├── inject.coffee │ └── matchers.coffee ├── unit │ ├── themes │ │ └── base.coffee │ ├── lib │ │ ├── range.coffee │ │ ├── color-picker.coffee │ │ └── picker.coffee │ ├── modules │ │ ├── tooltip.coffee │ │ ├── paste-manager.coffee │ │ ├── keyboard.coffee │ │ ├── undo-manager.coffee │ │ └── toolbar.coffee │ └── core │ │ ├── leaf.coffee │ │ └── format.coffee └── quill.coffee ├── History.md ├── .npmignore ├── component.json ├── examples ├── styles │ ├── advanced.styl │ └── style.styl ├── scripts │ └── advanced.coffee ├── index.jade └── advanced.jade ├── config ├── protractor.coverage.js ├── sauce.js ├── protractor.js ├── grunt │ ├── release.coffee │ ├── coverage.coffee │ ├── karma.coffee │ ├── protractor.coffee │ ├── dist.coffee │ └── server.coffee ├── browsers.js └── karma.js ├── .travis.yml ├── bower.json ├── docs └── style-guide.md ├── LICENSE ├── Gruntfile.coffee ├── package.json ├── dist └── quill.base.css └── README.md /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/quill'); 2 | -------------------------------------------------------------------------------- /src/themes/base/modules/toolbar.styl: -------------------------------------------------------------------------------- 1 | .ql-toolbar 2 | box-sizing: border-box 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.build 2 | /.coverage 3 | /dist 4 | /lib 5 | /node_modules 6 | *.log 7 | -------------------------------------------------------------------------------- /test/fixtures/style.css: -------------------------------------------------------------------------------- 1 | .editor-container > div { 2 | line-height: 25px; 3 | } 4 | -------------------------------------------------------------------------------- /src/themes/snow/assets/bold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/bold.png -------------------------------------------------------------------------------- /src/themes/snow/assets/left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/left.png -------------------------------------------------------------------------------- /src/themes/snow/assets/link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/link.png -------------------------------------------------------------------------------- /src/themes/snow/assets/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/list.png -------------------------------------------------------------------------------- /src/themes/snow/assets/bold@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/bold@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/bullet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/bullet.png -------------------------------------------------------------------------------- /src/themes/snow/assets/center.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/center.png -------------------------------------------------------------------------------- /src/themes/snow/assets/color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/color.png -------------------------------------------------------------------------------- /src/themes/snow/assets/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/image.png -------------------------------------------------------------------------------- /src/themes/snow/assets/italic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/italic.png -------------------------------------------------------------------------------- /src/themes/snow/assets/justify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/justify.png -------------------------------------------------------------------------------- /src/themes/snow/assets/left@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/left@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/link@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/link@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/list@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/list@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/right.png -------------------------------------------------------------------------------- /src/themes/snow/assets/strike.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/strike.png -------------------------------------------------------------------------------- /src/themes/snow/assets/video.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/video.png -------------------------------------------------------------------------------- /src/themes/snow/modules/link-tooltip.styl: -------------------------------------------------------------------------------- 1 | .ql-snow 2 | .ql-link-tooltip 3 | a, span 4 | line-height: 25px 5 | -------------------------------------------------------------------------------- /src/themes/base/modules/paste-manager.styl: -------------------------------------------------------------------------------- 1 | .ql-paste-manager 2 | left: -100000px 3 | position: absolute 4 | top: 50% 5 | -------------------------------------------------------------------------------- /src/themes/snow/assets/bullet@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/bullet@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/center@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/center@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/color@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/color@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/dropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/dropdown.png -------------------------------------------------------------------------------- /src/themes/snow/assets/image@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/image@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/italic@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/italic@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/right@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/right@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/strike@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/strike@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/underline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/underline.png -------------------------------------------------------------------------------- /src/themes/snow/assets/video@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/video@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/.DS_Store -------------------------------------------------------------------------------- /src/themes/snow/assets/active/bold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/bold.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/color.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/image.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/left.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/link.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/list.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/right.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/video.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/video.png -------------------------------------------------------------------------------- /src/themes/snow/assets/authorship.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/authorship.png -------------------------------------------------------------------------------- /src/themes/snow/assets/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/background.png -------------------------------------------------------------------------------- /src/themes/snow/assets/dropdown@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/dropdown@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/justify@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/justify@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/underline@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/underline@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/bold@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/bold@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/bullet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/bullet.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/center.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/center.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/indent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/indent.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/italic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/italic.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/justify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/justify.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/left@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/left@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/link@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/link@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/list@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/list@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/strike.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/strike.png -------------------------------------------------------------------------------- /src/themes/snow/assets/authorship@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/authorship@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/background@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/background@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/authorship.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/authorship.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/background.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/bullet@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/bullet@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/center@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/center@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/color@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/color@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/dropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/dropdown.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/image@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/image@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/indent@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/indent@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/italic@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/italic@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/justify@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/justify@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/right@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/right@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/strike@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/strike@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/underline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/underline.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/video@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/video@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/inactive/dropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/inactive/dropdown.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/dropdown@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/dropdown@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/underline@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/underline@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/authorship@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/authorship@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/active/background@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/active/background@2x.png -------------------------------------------------------------------------------- /src/themes/snow/assets/inactive/dropdown@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DemocracyOS/quill/master/src/themes/snow/assets/inactive/dropdown@2x.png -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | 2 | 0.19.12 / 2015-03-18 3 | ================== 4 | 5 | * remove HTTP being hardcoded into embed scripts - made it impossible to embed in HTTPS deployments 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .build 2 | .coverage 3 | .git 4 | .travis.yml 5 | _* 6 | *.log 7 | bower.json 8 | config 9 | dist/quill.min.js 10 | docs 11 | examples 12 | src 13 | test 14 | Gruntfile.coffee 15 | -------------------------------------------------------------------------------- /src/themes/snow/snow.styl: -------------------------------------------------------------------------------- 1 | @import '../base/base' 2 | 3 | // Make sure relative modules folder is included in both base and custom theme 4 | @import '../snow/modules/*' 5 | 6 | .ql-snow 7 | a 8 | color: #06c 9 | 10 | -------------------------------------------------------------------------------- /src/themes/snow/modules/image-tooltip.styl: -------------------------------------------------------------------------------- 1 | .ql-snow 2 | .ql-image-tooltip 3 | a 4 | border: 1px solid #06c 5 | a.insert 6 | background-color: #06c 7 | color: #fff 8 | .preview 9 | border-color: #ccc 10 | color: #ccc 11 | -------------------------------------------------------------------------------- /src/themes/snow/modules/video-tooltip.styl: -------------------------------------------------------------------------------- 1 | .ql-snow 2 | .ql-video-tooltip 3 | a 4 | border: 1px solid #06c 5 | a.insert 6 | background-color: #06c 7 | color: #fff 8 | .preview 9 | border-color: #ccc 10 | color: #ccc 11 | -------------------------------------------------------------------------------- /src/themes/snow/modules/tooltip.styl: -------------------------------------------------------------------------------- 1 | .ql-snow 2 | .ql-tooltip 3 | border: 1px solid #ccc 4 | box-shadow: 0px 0px 5px #ddd 5 | color: #222 6 | a 7 | color: #06c 8 | .input 9 | border: 1px solid #ccc 10 | margin: 0px 11 | padding: 5px 12 | -------------------------------------------------------------------------------- /src/themes/base/modules/tooltip.styl: -------------------------------------------------------------------------------- 1 | .ql-tooltip 2 | background-color: #fff 3 | border: 1px solid #000 4 | box-sizing: border-box 5 | position: absolute 6 | top: 0px 7 | white-space: nowrap 8 | z-index: 2000 9 | a 10 | cursor: pointer 11 | text-decoration: none 12 | -------------------------------------------------------------------------------- /test/helpers/coverage.coffee: -------------------------------------------------------------------------------- 1 | afterEach((done) -> 2 | return done() unless protractor.collector? 3 | browser.driver.switchTo().defaultContent(); 4 | browser.driver.executeScript('return window.__coverage__').then((coverage) -> 5 | protractor.collector.add(coverage) 6 | done() 7 | ) 8 | ) 9 | -------------------------------------------------------------------------------- /src/themes/base/modules/link-tooltip.styl: -------------------------------------------------------------------------------- 1 | .ql-link-tooltip 2 | padding: 5px 10px 3 | input.input 4 | width: 170px 5 | input.input, a.done 6 | display: none 7 | a.change 8 | margin-right: 4px 9 | .ql-link-tooltip.editing 10 | input.input, a.done 11 | display: inline-block 12 | a.url, a.change, a.remove 13 | display: none 14 | -------------------------------------------------------------------------------- /component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "democracyos-quill", 3 | "description": "A cross browser rich text editor with an API", 4 | "version": "0.20.1", 5 | "dependencies": {}, 6 | "main": "index.js", 7 | "scripts": [ 8 | "index.js", 9 | "dist/quill.js" 10 | ], 11 | "styles": [ 12 | "dist/quill.base.css", 13 | "dist/quill.snow.css" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /test/helpers/inject.coffee: -------------------------------------------------------------------------------- 1 | # Inject fixtures into DOM 2 | html = _.map(window.__html__, (html) -> 3 | return html 4 | ).join('') 5 | 6 | $div = $('
').attr('id', 'test-container') 7 | $(document.body).prepend($div) 8 | 9 | jasmine.clearContainer = -> 10 | return $div.html('
').get(0).firstChild 11 | 12 | jasmine.resetEditor = -> 13 | return $div.html(html).get(0).querySelector('#editor-container') 14 | -------------------------------------------------------------------------------- /src/index.coffee: -------------------------------------------------------------------------------- 1 | require('./modules/authorship') 2 | require('./modules/image-tooltip') 3 | require('./modules/video-tooltip') 4 | require('./modules/keyboard') 5 | require('./modules/link-tooltip') 6 | require('./modules/multi-cursor') 7 | require('./modules/paste-manager') 8 | require('./modules/toolbar') 9 | require('./modules/tooltip') 10 | require('./modules/undo-manager') 11 | 12 | module.exports = require('./quill') 13 | -------------------------------------------------------------------------------- /src/lib/color-picker.coffee: -------------------------------------------------------------------------------- 1 | dom = require('./dom') 2 | Picker = require('./picker') 3 | 4 | 5 | class ColorPicker extends Picker 6 | constructor: -> 7 | super 8 | dom(@container).addClass('ql-color-picker') 9 | 10 | buildItem: (picker, option, index) -> 11 | item = super(picker, option, index) 12 | item.style.backgroundColor = option.value 13 | return item 14 | 15 | 16 | module.exports = ColorPicker 17 | -------------------------------------------------------------------------------- /test/unit/themes/base.coffee: -------------------------------------------------------------------------------- 1 | describe('Base Theme', -> 2 | it('objToCss()', -> 3 | css = Quill.Theme.Base.objToCss( 4 | '.editor-container a': 5 | 'font-style': 'italic' 6 | 'text-decoration': 'underline' 7 | '.editor-container b': 8 | 'font-weight': 'bold' 9 | ) 10 | expect(css).toEqual([ 11 | '.editor-container a { font-style: italic; text-decoration: underline; }' 12 | '.editor-container b { font-weight: bold; }' 13 | ].join('\n')) 14 | ) 15 | ) 16 | -------------------------------------------------------------------------------- /examples/styles/advanced.styl: -------------------------------------------------------------------------------- 1 | .basic-wrapper, .advanced-wrapper 2 | border: 1px solid #ccc 3 | float: left 4 | margin-left: 3% 5 | margin-top: 15px 6 | margin-bottom: 15px 7 | 8 | .basic-wrapper 9 | width: 40% 10 | 11 | .advanced-wrapper 12 | width: 50% 13 | 14 | .editor-container 15 | height: 400px 16 | 17 | .toolbar-container 18 | border-bottom: 1px solid #ccc 19 | 20 | .basic-wrapper .toolbar-container 21 | padding: 8px 14px 22 | .ql-active, button:hover 23 | color: green 24 | font-weight: bold 25 | -------------------------------------------------------------------------------- /examples/styles/style.styl: -------------------------------------------------------------------------------- 1 | body 2 | font-family: 'Helvetica', 'Arial', san-serif 3 | font-size: 13px 4 | padding: 25px 5 | 6 | #content-container 7 | margin: auto 8 | width: 960px 9 | 10 | #formatting-container 11 | background-color: #f5f5f5 12 | border-bottom: 1px solid #ccc 13 | padding: 5px 12px 14 | .ql-active, button:hover 15 | color: green 16 | font-weight: bold 17 | 18 | #editor-container 19 | height: 600px 20 | 21 | #editor-wrapper 22 | border: 1px solid #aaa 23 | box-shadow: 0 0 2px 2px #ddd 24 | -------------------------------------------------------------------------------- /config/protractor.coverage.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var istanbul = require('istanbul'); 3 | var config = require('./protractor.js').config; 4 | 5 | config.onPrepare = _.wrap(config.onPrepare, function(onPrepare) { 6 | onPrepare(); 7 | protractor.collector = new istanbul.Collector(); 8 | }); 9 | 10 | config.onComplete = function() { 11 | var reporter = new istanbul.Reporter(null, '.coverage/protractor'); 12 | reporter.add('json'); 13 | reporter.write(protractor.collector, false, function() {}); 14 | }; 15 | 16 | exports.config = config; 17 | -------------------------------------------------------------------------------- /test/fixtures/unit.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 12 |
13 |
14 | -------------------------------------------------------------------------------- /config/sauce.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var os = require('os'); 3 | 4 | var options = { 5 | username: process.env.SAUCE_USER || 'quill', 6 | accessKey: process.env.SAUCE_KEY || 'adc0c0cf-221b-46f1-81b9-a4429b722c2e' 7 | }; 8 | 9 | if (process.env.TRAVIS) { 10 | options.build = process.env.TRAVIS_BUILD_ID; 11 | options.tunnel = process.env.TRAVIS_JOB_NUMBER; 12 | } else { 13 | var id = _.random(16*16*16*16).toString(16); 14 | options.build = os.hostname() + '-' + id; 15 | options.tunnel = os.hostname() + '-tunnel-' + id; 16 | } 17 | 18 | module.exports = options; 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: '0.12' 3 | script: 4 | - grunt travis:$TEST 5 | sudo: false 6 | cache: 7 | directories: 8 | - node_modules 9 | env: 10 | matrix: 11 | - TEST=unit-mac-chrome 12 | - TEST=unit-mac-firefox 13 | - TEST=unit-mac-safari 14 | - TEST=unit-windows-firefox 15 | - TEST=unit-windows-chrome 16 | - TEST=unit-windows-ie-11 17 | - TEST=unit-windows-ie-10 18 | - TEST=unit-windows-ie-9 19 | - TEST=unit-linux-chrome 20 | - TEST=unit-linux-firefox 21 | - TEST=unit-ipad 22 | - TEST=unit-iphone 23 | - TEST=unit-android 24 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "democracyos-quill", 3 | "version": "0.20.1", 4 | "homepage": "http://quilljs.com", 5 | "authors": ["Jason Chen "], 6 | "contributors": [ 7 | "Byron Milligan ", 8 | "Keegan Poppen " 9 | ], 10 | "description": "Cross browser rich text editor", 11 | "main": "dist/quill.js", 12 | "license": "BSD", 13 | "ignore": [ 14 | "**/.*", 15 | "config", 16 | "examples", 17 | "src", 18 | "test", 19 | "Gruntfile.coffee", 20 | "index.js", 21 | "package.json" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/themes/base/modules/multi-cursor.styl: -------------------------------------------------------------------------------- 1 | .ql-multi-cursor 2 | position: absolute 3 | left: 0 4 | top: 0 5 | z-index: 1000 6 | .cursor 7 | margin-left: -1px 8 | position: absolute 9 | .cursor-flag 10 | bottom: 100% 11 | position: absolute 12 | white-space: nowrap 13 | .cursor-name 14 | display: inline-block 15 | color: white 16 | padding: 2px 8px 17 | .cursor-caret 18 | height: 100% 19 | position: absolute 20 | width: 2px 21 | .cursor.hidden .cursor-flag 22 | display: none 23 | .cursor.top .cursor-flag 24 | bottom: auto 25 | top: 100% 26 | .cursor.right .cursor-flag 27 | right: -2px 28 | -------------------------------------------------------------------------------- /config/protractor.js: -------------------------------------------------------------------------------- 1 | require('coffee-script'); 2 | 3 | exports.config = { 4 | chromeOnly: false, 5 | allScriptsTimeout: 11000, 6 | 7 | capabilities: { 8 | 'browserName': 'chrome' 9 | }, 10 | 11 | suites: { 12 | helper: '../../test/helpers/matchers.coffee', 13 | e2e: '../../test/e2e/*.coffee', 14 | wd: '../../test/wd/*.coffee' 15 | }, 16 | 17 | onPrepare: function() { 18 | browser.ignoreSynchronization = true; 19 | }, 20 | 21 | framework: 'jasmine', 22 | 23 | jasmineNodeOpts: { 24 | onComplete: null, 25 | isVerbose: true, 26 | showColors: true, 27 | includeStackTrace: true, 28 | defaultTimeoutInterval: 30000 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /config/grunt/release.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (grunt) -> 2 | grunt.config('compress', 3 | dist: 4 | options: 5 | archive: '.build/quill.tar.gz' 6 | mode: 'tgz' 7 | files: [{ 8 | cwd: '.build/quill' 9 | src: ['**/*'] 10 | dest: 'quill/' 11 | expand: true 12 | }] 13 | ) 14 | 15 | grunt.config('copy', 16 | dist: 17 | files: [{ 18 | src: 'dist/*' 19 | dest: '.build/quill/' 20 | expand: true 21 | flatten: true 22 | }] 23 | ) 24 | 25 | grunt.registerTask('examples', -> 26 | grunt.util.spawn( 27 | cmd: './node_modules/.bin/harp' 28 | args: ['compile', 'examples/', '.build/quill/examples/'] 29 | opts: 30 | stdio: 'inherit' 31 | , this.async()) 32 | ) 33 | -------------------------------------------------------------------------------- /src/lib/range.coffee: -------------------------------------------------------------------------------- 1 | _ = require('lodash') 2 | 3 | 4 | class Range 5 | @compare: (r1, r2) -> 6 | return true if r1 == r2 # Covers both is null case 7 | return false unless r1? and r2? # If either is null they are not equal 8 | return r1.equals(r2) 9 | 10 | constructor: (@start, @end) -> 11 | 12 | equals: (range) -> 13 | return false unless range? 14 | return @start == range.start and @end == range.end 15 | 16 | shift: (index, length) -> 17 | [@start, @end] = _.map([@start, @end], (pos) -> 18 | return pos if index > pos 19 | if length >= 0 20 | return pos + length 21 | else 22 | return Math.max(index, pos + length) 23 | ) 24 | 25 | isCollapsed: -> 26 | return @start == @end 27 | 28 | 29 | module.exports = Range 30 | -------------------------------------------------------------------------------- /src/themes/base/modules/image-tooltip.styl: -------------------------------------------------------------------------------- 1 | .ql-image-tooltip 2 | padding: 10px 3 | width: 300px 4 | &:after 5 | clear: both 6 | content: "" 7 | display: table 8 | a 9 | border: 1px solid black 10 | box-sizing: border-box 11 | display: inline-block 12 | float: left 13 | padding: 5px 14 | text-align: center 15 | width: 50% 16 | img 17 | bottom: 0 18 | left: 0 19 | margin: auto 20 | max-height: 100% 21 | max-width: 100% 22 | position: absolute 23 | right: 0 24 | top: 0 25 | .input 26 | box-sizing: border-box 27 | width: 100% 28 | .preview 29 | margin: 10px 0px 30 | position: relative 31 | border: 1px dashed #000 32 | height: 200px 33 | span 34 | display: inline-block 35 | position: absolute 36 | text-align: center 37 | top: 40% 38 | width: 100% 39 | -------------------------------------------------------------------------------- /src/themes/base/modules/video-tooltip.styl: -------------------------------------------------------------------------------- 1 | .ql-video-tooltip 2 | padding: 10px 3 | width: 300px 4 | &:after 5 | clear: both 6 | content: "" 7 | display: table 8 | a 9 | border: 1px solid black 10 | box-sizing: border-box 11 | display: inline-block 12 | float: left 13 | padding: 5px 14 | text-align: center 15 | width: 50% 16 | img 17 | bottom: 0 18 | left: 0 19 | margin: auto 20 | max-height: 100% 21 | max-width: 100% 22 | position: absolute 23 | right: 0 24 | top: 0 25 | .input 26 | box-sizing: border-box 27 | width: 100% 28 | .preview 29 | margin: 10px 0px 30 | position: relative 31 | border: 1px dashed #000 32 | height: 200px 33 | iframe 34 | height 196px 35 | width 272px 36 | span 37 | display: inline-block 38 | position: absolute 39 | text-align: center 40 | top: 40% 41 | width: 100% 42 | -------------------------------------------------------------------------------- /config/grunt/coverage.coffee: -------------------------------------------------------------------------------- 1 | async = require('async') 2 | fs = require('fs') 3 | glob = require('glob') 4 | istanbul = require('istanbul') 5 | collector = new istanbul.Collector() 6 | reporter = new istanbul.Reporter(null, '.coverage/merged') 7 | 8 | module.exports = (grunt) -> 9 | grunt.registerTask('istanbul:instrument', -> 10 | grunt.util.spawn( 11 | cmd: './node_modules/.bin/istanbul' 12 | args: ['instrument', 'lib/', '-o', 'src/'] 13 | opts: 14 | stdio: 'inherit' 15 | , this.async()) 16 | ) 17 | 18 | grunt.registerTask('istanbul:report', -> 19 | done = this.async() 20 | glob('.coverage/**/*.json', (er, files) -> 21 | async.each(files, (file, callback) -> 22 | fs.readFile(file, 'utf8', (err, data) -> 23 | collector.add(JSON.parse(data)) 24 | callback() 25 | ) 26 | , (err) -> 27 | reporter.addAll(['html', 'text']) 28 | reporter.write(collector, false, done) 29 | ) 30 | ) 31 | ) 32 | -------------------------------------------------------------------------------- /src/themes/snow/modules/multi-cursor.styl: -------------------------------------------------------------------------------- 1 | .ql-snow 2 | .ql-multi-cursor 3 | .cursor-name 4 | border-radius: 4px 5 | font-size: 11px 6 | font-family: Arial 7 | margin-left: -50% 8 | padding: 4px 10px 9 | .cursor-triangle 10 | border-left: 4px solid transparent 11 | border-right: 4px solid transparent 12 | height: 0px 13 | margin-left: -3px 14 | width: 0px 15 | .cursor.left .cursor-name 16 | margin-left: -8px 17 | .cursor.right 18 | .cursor-flag 19 | right: auto 20 | .cursor-name 21 | margin-left: -100% 22 | margin-right: -8px 23 | .cursor-triangle.bottom 24 | border-top: 4px solid transparent 25 | display: block 26 | margin-bottom: -1px 27 | .cursor-triangle.top 28 | border-bottom: 4px solid transparent 29 | display: none 30 | margin-top: -1px 31 | .cursor.top 32 | .cursor-triangle.bottom 33 | display: none 34 | .cursor-triangle.top 35 | display: block 36 | -------------------------------------------------------------------------------- /src/themes/base/index.coffee: -------------------------------------------------------------------------------- 1 | _ = require('lodash') 2 | dom = require('../../lib/dom') 3 | baseStyles = require('./base.styl') 4 | 5 | 6 | class BaseTheme 7 | @OPTIONS: {} 8 | 9 | @objToCss: (obj) -> 10 | return _.map(obj, (value, key) -> 11 | innerStr = _.map(value, (innerValue, innerKey) -> 12 | return "#{innerKey}: #{innerValue};" 13 | ).join(' ') 14 | return "#{key} { #{innerStr} }" 15 | ).join("\n") 16 | 17 | constructor: (@quill, @options) -> 18 | dom(@quill.container).addClass('ql-container') 19 | if @options.styles 20 | this.addStyles(baseStyles + BaseTheme.objToCss(@options.styles)) 21 | if dom.isIE(10) 22 | version = if dom.isIE(9) then '9' else '10' 23 | dom(@quill.root).addClass('ql-ie-' + version) 24 | 25 | addStyles: (css) -> 26 | css = BaseTheme.objToCss(css) if _.isObject(css) 27 | style = document.createElement('style') 28 | style.type = 'text/css' 29 | style.appendChild(document.createTextNode(css)) 30 | document.head.appendChild(style) 31 | 32 | 33 | module.exports = BaseTheme 34 | -------------------------------------------------------------------------------- /config/browsers.js: -------------------------------------------------------------------------------- 1 | var CHROME_VERSION = '43'; 2 | var FIREFOX_VERSION = '38'; 3 | var SAFARI_VERSION = '8'; 4 | var IOS_VERSION = ' 8.2'; // Workaround for optimist converting to float 5 | var ANDROID_VERSION = ' 5.1'; 6 | 7 | var browsers = { 8 | 'mac-chrome' : ['Mac 10.10', 'chrome', CHROME_VERSION], 9 | 'mac-firefox' : ['Mac 10.10', 'firefox', FIREFOX_VERSION], 10 | 'mac-safari' : ['Mac 10.10', 'safari', SAFARI_VERSION], 11 | 12 | 'windows-chrome' : ['Windows 8.1', 'chrome', CHROME_VERSION], 13 | 'windows-firefox' : ['Windows 8.1', 'firefox', FIREFOX_VERSION], 14 | 'windows-ie-11' : ['Windows 8.1', 'internet explorer', '11'], 15 | 16 | 'windows-ie-10' : ['Windows 7', 'internet explorer', '10'], 17 | 'windows-ie-9' : ['Windows 7', 'internet explorer', '9'], 18 | 19 | 'linux-chrome' : ['Linux', 'chrome', CHROME_VERSION], 20 | 'linux-firefox' : ['Linux', 'firefox', FIREFOX_VERSION], 21 | 22 | 'iphone' : ['Mac 10.10', 'iphone', IOS_VERSION], 23 | 'ipad' : ['Mac 10.10', 'ipad', IOS_VERSION], 24 | 'android' : ['Linux', 'android', ANDROID_VERSION] 25 | }; 26 | 27 | module.exports = browsers; 28 | -------------------------------------------------------------------------------- /src/themes/base/base.styl: -------------------------------------------------------------------------------- 1 | // Make sure relative modules folder is included in both base and custom theme 2 | @import '../base/modules/*' 3 | 4 | .ql-container 5 | box-sizing: border-box 6 | cursor: text 7 | font-family: Helvetica, 'Arial', sans-serif 8 | font-size: 13px 9 | height: 100% 10 | line-height: 1.42 11 | margin: 0px 12 | overflow-x: hidden 13 | overflow-y: auto 14 | padding: 12px 15px 15 | position: relative 16 | 17 | .ql-editor 18 | box-sizing: border-box 19 | min-height: 100% 20 | outline: none 21 | tab-size: 4 22 | white-space: pre-wrap 23 | div 24 | margin: 0 25 | padding: 0 26 | a 27 | text-decoration: underline 28 | b 29 | font-weight: bold 30 | i 31 | font-style: italic 32 | s 33 | text-decoration: line-through 34 | u 35 | text-decoration: underline 36 | a, b, i, s, u, span 37 | background-color: inherit 38 | img 39 | max-width: 100% 40 | blockquote, ol, ul 41 | margin: 0 0 0 2em 42 | padding: 0 43 | ol 44 | list-style-type: decimal 45 | ul 46 | list-style-type: disc 47 | 48 | .ql-editor.ql-ie-9, .ql-editor.ql-ie-10 49 | br 50 | display: none 51 | -------------------------------------------------------------------------------- /config/karma.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var browsers = require('./browsers'); 3 | var sauce = require('./sauce'); 4 | 5 | var customLaunchers = _.reduce(browsers, function(memo, browser, name) { 6 | memo[name] = { 7 | base: 'SauceLabs', 8 | platform: browser[0], 9 | browserName: browser[1], 10 | version: browser[2] 11 | }; 12 | return memo; 13 | }, {}); 14 | 15 | module.exports = function(config) { 16 | config.set({ 17 | basePath: '../', 18 | frameworks: ['jasmine'], 19 | coverageReporter: { 20 | type: 'json', 21 | dir: '.coverage/karma/' 22 | }, 23 | reporters: ['progress'], 24 | preprocessors: { 25 | '**/*.coffee': ['coffee'], 26 | '**/*.html': ['html2js'] 27 | }, 28 | colors: true, 29 | logLevel: config.LOG_INFO, 30 | autoWatch: false, 31 | singleRun: true, 32 | sauceLabs: { 33 | testName: 'quill-unit', 34 | options: { 35 | 'public': 'public', 36 | 'record-screenshots': false 37 | }, 38 | build: sauce.build, 39 | username: sauce.username, 40 | accessKey: sauce.accessKey, 41 | tunnelIdentifier: sauce.tunnel 42 | }, 43 | customLaunchers: customLaunchers 44 | }) 45 | 46 | if (process.env.TRAVIS) { 47 | config.transports = ['xhr-polling']; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /docs/style-guide.md: -------------------------------------------------------------------------------- 1 | # Style Guide 2 | 3 | Code style is very subjective but consistency is very important for a healthy codebase. Quill strives to follow good programming practices and the language specific guidelines so those will not be reproduced here. However some less obvious guidelines are listed below. 4 | 5 | If there is ever uncertainty, please look at other parts of the codebase and mimic that style. 6 | 7 | 8 | ### General 9 | 10 | - Use two spaces for tabs 11 | - No trailing whitespace on any lines 12 | 13 | 14 | ### Operators 15 | 16 | - Always use parenthesis for function calls 17 | - Use brackets for one line object definitions 18 | 19 | ```coffeescript 20 | console.log('Yes') # Yes 21 | console.log 'No' # No 22 | 23 | config = { attack: 10, defense: 10 } # Yes 24 | config = attack: 10, defense: 10 # No 25 | 26 | # Okay 27 | config = 28 | attack: 10 29 | defense: 10 30 | ``` 31 | 32 | 33 | ### Classes 34 | 35 | - Use an explicit `this` when referencing methods 36 | - Use `@` when referencing instance variables 37 | 38 | ```coffeescript 39 | class Tower 40 | @constructor: (strength, toughness) -> 41 | @strength = strength # Yes 42 | this.toughness = toughness # No 43 | 44 | this.attack() # Yes 45 | @defend() # No 46 | 47 | attack: -> 48 | 49 | defend: -> 50 | ``` 51 | -------------------------------------------------------------------------------- /src/core/leaf.coffee: -------------------------------------------------------------------------------- 1 | _ = require('lodash') 2 | dom = require('../lib/dom') 3 | Format = require('./format') 4 | LinkedList = require('../lib/linked-list') 5 | 6 | 7 | class Leaf extends LinkedList.Node 8 | @DATA_KEY: 'leaf' 9 | 10 | @isLeafNode: (node) -> 11 | return dom(node).isTextNode() or !node.firstChild? 12 | 13 | constructor: (@node, formats) -> 14 | @formats = _.clone(formats) 15 | @text = dom(@node).text() 16 | @length = @text.length 17 | dom(@node).data(Leaf.DATA_KEY, this) 18 | 19 | deleteText: (offset, length) -> 20 | return unless length > 0 21 | @text = @text.slice(0, offset) + @text.slice(offset + length) 22 | @length = @text.length 23 | if dom.EMBED_TAGS[@node.tagName]? 24 | textNode = document.createTextNode(@text) 25 | dom(textNode).data(Leaf.DATA_KEY, this) 26 | @node = dom(@node).replace(textNode).get() 27 | else 28 | dom(@node).text(@text) 29 | 30 | insertText: (offset, text) -> 31 | @text = @text.slice(0, offset) + text + @text.slice(offset) 32 | if dom(@node).isTextNode() 33 | dom(@node).text(@text) 34 | else 35 | textNode = document.createTextNode(text) 36 | dom(textNode).data(Leaf.DATA_KEY, this) 37 | if @node.tagName == dom.DEFAULT_BREAK_TAG 38 | @node = dom(@node).replace(textNode).get() 39 | else 40 | @node.appendChild(textNode) 41 | @node = textNode 42 | @length = @text.length 43 | 44 | 45 | module.exports = Leaf 46 | -------------------------------------------------------------------------------- /src/lib/linked-list.coffee: -------------------------------------------------------------------------------- 1 | # Inspired by http://blog.jcoglan.com/2007/07/23/writing-a-linked-list-in-javascript/ 2 | 3 | class Node 4 | constructor: (@data) -> 5 | @prev = @next = null 6 | 7 | 8 | class LinkedList 9 | @Node: Node 10 | 11 | constructor: -> 12 | @length = 0 13 | @first = @last = null 14 | 15 | append: (node) -> 16 | if @first? 17 | node.next = null 18 | @last.next = node 19 | else 20 | @first = node 21 | node.prev = @last 22 | @last = node 23 | @length += 1 24 | 25 | insertAfter: (refNode, newNode) -> 26 | newNode.prev = refNode 27 | if refNode? 28 | newNode.next = refNode.next 29 | refNode.next.prev = newNode if refNode.next? 30 | refNode.next = newNode 31 | @last = newNode if refNode == @last 32 | else # Insert after null implies inserting at position 0 33 | newNode.next = @first 34 | @first.prev = newNode 35 | @first = newNode 36 | @length += 1 37 | 38 | remove: (node) -> 39 | if @length > 1 40 | node.prev.next = node.next if node.prev? 41 | node.next.prev = node.prev if node.next? 42 | @first = node.next if node == @first 43 | @last = node.prev if node == @last 44 | else 45 | @first = @last = null 46 | node.prev = node.next = null 47 | @length -= 1 48 | 49 | toArray: -> 50 | arr = [] 51 | cur = @first 52 | while cur? 53 | arr.push(cur) 54 | cur = cur.next 55 | return arr 56 | 57 | 58 | module.exports = LinkedList 59 | -------------------------------------------------------------------------------- /test/fixtures/memory.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Quill Memery Test 5 | 6 | 17 | 18 | 19 |
20 | 21 | 22 |
23 |
24 | 25 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Jason Chen 2 | Copyright (c) 2013, salesforce.com 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions 7 | are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright 13 | notice, this list of conditions and the following disclaimer in the 14 | documentation and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 21 | IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 22 | TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 23 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /test/quill.coffee: -------------------------------------------------------------------------------- 1 | Quill = require('../src/quill') 2 | 3 | Quill.Delta = require('rich-text/lib/delta') 4 | 5 | Quill.Document = require('../src/core/document') 6 | Quill.Editor = require('../src/core/editor') 7 | Quill.Format = require('../src/core/format') 8 | Quill.Leaf = require('../src/core/leaf') 9 | Quill.Line = require('../src/core/line') 10 | Quill.Normalizer = require('../src/core/normalizer') 11 | Quill.Selection = require('../src/core/selection') 12 | 13 | Quill.Lib = 14 | EventEmitter2 : require('eventemitter2').EventEmitter2 15 | ColorPicker : require('../src/lib/color-picker') 16 | DOM : require('../src/lib/dom') 17 | LinkedList : require('../src/lib/linked-list') 18 | Picker : require('../src/lib/picker') 19 | Range : require('../src/lib/range') 20 | 21 | Quill.Module = 22 | Authorship : require('../src/modules/authorship') 23 | Keyboard : require('../src/modules/keyboard') 24 | ImageTooltip : require('../src/modules/image-tooltip') 25 | VideoTooltip : require('../src/modules/video-tooltip') 26 | LinkTooltip : require('../src/modules/link-tooltip') 27 | MultiCursor : require('../src/modules/multi-cursor') 28 | PasteManager : require('../src/modules/paste-manager') 29 | Toolbar : require('../src/modules/toolbar') 30 | Tooltip : require('../src/modules/tooltip') 31 | UndoManager : require('../src/modules/undo-manager') 32 | 33 | Quill.Theme = 34 | Base : require('../src/themes/base') 35 | Snow : require('../src/themes/snow') 36 | 37 | 38 | Quill.DEFAULTS.pollInterval = 10000000 39 | Quill.DEFAULTS.style = false 40 | 41 | 42 | module.exports = Quill 43 | -------------------------------------------------------------------------------- /test/unit/lib/range.coffee: -------------------------------------------------------------------------------- 1 | describe('Range', -> 2 | describe('shift()', -> 3 | tests = 4 | 'before': 5 | initial: [10, 20] 6 | index: 5, length: 5 7 | expected: [15, 25] 8 | 'between': 9 | initial: [10, 20] 10 | index: 15, length: 2 11 | expected: [10, 22] 12 | 'after': 13 | initial: [10, 20] 14 | index: 25, length: 5 15 | expected: [10, 20] 16 | 'on cursor': 17 | initial: [10, 10] 18 | index: 10, length: 5 19 | expected: [15, 15] 20 | 'on start': 21 | initial: [10, 20] 22 | index: 10, length: 5 23 | expected: [15, 25] 24 | 'on end': 25 | initial: [10, 20] 26 | index: 20, length: 5 27 | expected: [10, 25] 28 | 'between remove': 29 | initial: [10, 20] 30 | index: 15, length: -2 31 | expected: [10, 18] 32 | 'before remove beyond start': 33 | initial: [10, 20] 34 | index: 5, length: -10 35 | expected: [5, 10] 36 | 'after remove': 37 | initial: [10, 20] 38 | index: 25, length: -20 39 | expected: [10, 20] 40 | 'remove on cursor': 41 | initial: [10, 10] 42 | index: 10, length: -5 43 | expected: [10, 10] 44 | 'after remove beyond start': 45 | initial: [10, 10] 46 | index: 5, length: -50 47 | expected: [5, 5] 48 | 49 | _.each(tests, (test, name) -> 50 | it(name, -> 51 | range = new Quill.Lib.Range(test.initial[0], test.initial[1]) 52 | range.shift(test.index, test.length) 53 | expect(range.start).toEqual(test.expected[0]) 54 | expect(range.end).toEqual(test.expected[1]) 55 | ) 56 | ) 57 | ) 58 | ) 59 | -------------------------------------------------------------------------------- /test/unit/lib/color-picker.coffee: -------------------------------------------------------------------------------- 1 | describe('ColorPicker', -> 2 | it('constructor', -> 3 | container = $('#test-container').html(Quill.Normalizer.stripWhitespace(' 4 | 12 | ')).get(0) 13 | select = container.querySelector('select') 14 | picker = new Quill.Lib.ColorPicker(select) 15 | expect(container.querySelector('.ql-color-picker').outerHTML).toEqualHTML(' 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ') 28 | ) 29 | ) 30 | -------------------------------------------------------------------------------- /config/grunt/karma.coffee: -------------------------------------------------------------------------------- 1 | _ = require('lodash') 2 | browsers = require('../browsers') 3 | 4 | remoteReporters = ['dots'] 5 | remoteReporters.push('saucelabs') if process.env.TRAVIS_BRANCH == 'master' 6 | 7 | remoteKarma = _.reduce(browsers, (memo, config, browser) -> 8 | memo[browser] = 9 | browsers: [browser] 10 | browserDisconnectTimeout: 10000 11 | browserDisconnectTolerance: 4 12 | browserNoActivityTimeout: 60000 13 | reporters: remoteReporters 14 | return memo 15 | , {}) 16 | 17 | module.exports = (grunt) -> 18 | grunt.config('karma', _.extend(remoteKarma, 19 | options: 20 | configFile: 'config/karma.js' 21 | files: [ 22 | 'node_modules/jquery/dist/jquery.js' 23 | 'node_modules/lodash/index.js' 24 | 25 | grunt.config('baseUrl') + 'quill.base.css' 26 | grunt.config('baseUrl') + 'test/quill.js' 27 | 28 | 'test/fixtures/unit.html' 29 | 'test/helpers/inject.coffee' 30 | 'test/helpers/matchers.coffee' 31 | 32 | { pattern: 'test/fixtures/*.css', included: false } 33 | 34 | # We dont do **/*.coffee to control order of tests 35 | 'test/unit/lib/*.coffee' 36 | 'test/unit/core/*.coffee' 37 | 'test/unit/modules/*.coffee' 38 | 'test/unit/themes/*.coffee' 39 | ] 40 | port: grunt.config('karmaPort') 41 | coverage: 42 | browserNoActivityTimeout: 30000 43 | browsers: ['Chrome'] 44 | reporters: ['coverage'] 45 | local: 46 | browsers: ['Chrome', 'Firefox', 'Safari'] 47 | server: 48 | autoWatch: true 49 | browsers: [] 50 | urlRoot: '/karma/' # TODO move this back into karma.js as soon as urlRoot works for socket.io again 51 | singleRun: false 52 | test: 53 | browsers: ['Chrome'] 54 | )) 55 | -------------------------------------------------------------------------------- /Gruntfile.coffee: -------------------------------------------------------------------------------- 1 | _ = require('lodash') 2 | fs = require('fs') 3 | browsers = require('./config/browsers') 4 | 5 | GRUNT_DIR = 'config/grunt' 6 | 7 | module.exports = (grunt) -> 8 | require('load-grunt-tasks')(grunt) 9 | 10 | grunt.initConfig( 11 | pkg: grunt.file.readJSON('package.json') 12 | port: 9000 13 | karmaPort: 9876 14 | ) 15 | 16 | if (grunt.option('host')) 17 | grunt.config('baseUrl', "http://#{grunt.option('host')}/") 18 | else 19 | grunt.config('baseUrl', "http://localhost:#{grunt.config('port')}/") 20 | 21 | files = fs.readdirSync(GRUNT_DIR) 22 | files.forEach((file) -> 23 | require("./#{GRUNT_DIR}/#{file}")(grunt) 24 | ) 25 | 26 | grunt.registerTask('dev', ['connect:server', 'karma:server']) 27 | 28 | grunt.registerTask('dist', ['clean', 'lodash', 'browserify', 'uglify', 'stylus', 'concat']) 29 | grunt.registerTask('release', ['dist', 'examples', 'copy', 'compress']) 30 | 31 | grunt.registerTask('server', ['connect:server:keepalive']) 32 | 33 | grunt.registerTask('test', ['test:unit']) 34 | 35 | grunt.registerTask('test:unit', ['connect:server', 'karma:test']) 36 | grunt.registerTask('test:wd', ['connect:server', 'protractor:test']) 37 | grunt.registerTask('test:e2e', ['connect:server', 'protractor:e2e']) 38 | 39 | grunt.registerTask('test:coverage', [ 40 | 'lodash', 'coffee:quill', 'istanbul:instrument' 41 | 'connect:server', 'karma:coverage', 'istanbul:report' 42 | 'clean:coverage' 43 | ]) 44 | 45 | _.each(browsers, (config, browser) -> 46 | grunt.registerTask("travis:unit-#{browser}", ['connect:server', "karma:#{browser}"]) 47 | grunt.registerTask("travis:wd-#{browser}", ['connect:server', 'sauce_connect:quill', "protractor:wd-#{browser}"]) 48 | grunt.registerTask("travis:e2e-#{browser}", ['connect:server', 'sauce_connect:quill', "protractor:e2e-#{browser}"]) 49 | ) 50 | -------------------------------------------------------------------------------- /src/modules/paste-manager.coffee: -------------------------------------------------------------------------------- 1 | Quill = require('../quill') 2 | Document = require('../core/document') 3 | _ = Quill.require('lodash') 4 | dom = Quill.require('dom') 5 | Delta = Quill.require('delta') 6 | 7 | class PasteManager 8 | @DEFAULTS: 9 | onConvert: null 10 | 11 | constructor: (@quill, options) -> 12 | @container = @quill.addContainer('ql-paste-manager') 13 | @container.setAttribute('contenteditable', true) 14 | @container.setAttribute('tabindex', '-1') 15 | dom(@quill.root).on('paste', _.bind(this._paste, this)) 16 | @options = _.defaults(options, PasteManager.DEFAULTS) 17 | @options.onConvert ?= this._onConvert; 18 | 19 | _onConvert: (container) => 20 | doc = new Document(container, @quill.options) 21 | delta = doc.toDelta() 22 | lengthAdded = delta.length() 23 | if lengthAdded == 0 24 | return delta 25 | # Need to remove trailing newline so paste is inline, losing format is expected and observed in Word 26 | return delta.compose(new Delta().retain(lengthAdded - 1).delete(1)) 27 | 28 | _paste: -> 29 | oldDocLength = @quill.getLength() 30 | range = @quill.getSelection() 31 | return unless range? 32 | @container.focus() 33 | _.defer( => 34 | delta = @options.onConvert(@container) 35 | lengthAdded = delta.length() 36 | if lengthAdded > 0 37 | delta.ops.unshift({ retain: range.start }) if range.start > 0 38 | delta.delete(range.end - range.start) 39 | @quill.updateContents(delta, 'user') 40 | @quill.setSelection(range.start + lengthAdded, range.start + lengthAdded) 41 | # Make sure bottom of pasted content is visible 42 | @quill.editor.selection.scrollIntoView() 43 | @container.innerHTML = "" 44 | ) 45 | 46 | 47 | Quill.registerModule('paste-manager', PasteManager) 48 | module.exports = PasteManager 49 | -------------------------------------------------------------------------------- /test/unit/modules/tooltip.coffee: -------------------------------------------------------------------------------- 1 | describe('Tooltip', -> 2 | makeBounder = (left, top, width, height, scrollTop = 0) -> 3 | return { 4 | getBoundingClientRect: -> 5 | return { left: left, top: top, right: left + width, bottom: top + height, width: width, height: height } 6 | } 7 | 8 | beforeEach( -> 9 | @container = jasmine.clearContainer() 10 | @quill = new Quill(@container) 11 | @tooltip = @quill.addModule('tooltip', { offset: 20 }) 12 | ) 13 | 14 | describe('show/hide', -> 15 | it('restore range', -> 16 | @quill.setSelection(0, 0) 17 | @tooltip.show() 18 | @tooltip.container.focus() 19 | @tooltip.hide() 20 | range = @quill.getSelection() 21 | expect(range.start).toEqual(0) 22 | expect(range.end).toEqual(0) 23 | ) 24 | ) 25 | 26 | describe('position()', -> 27 | beforeEach( -> 28 | $(@quill.container).css({ width: 600, height: 400 }) 29 | $(@tooltip.container).css({ width: 200, height: 100 }) 30 | 31 | ) 32 | 33 | it('no reference', -> 34 | [left, top] = @tooltip.position() 35 | expect(left).toEqual(200) 36 | expect(top).toEqual(150) 37 | ) 38 | 39 | it('place below', -> 40 | reference = @quill.addContainer('ql-reference') 41 | $(reference).css({ position: 'absolute', top: '100px', left: '200px', width: '100px', height: '50px' }) 42 | [left, top] = @tooltip.position(reference) 43 | expect(left).toEqual(150) 44 | expect(top).toEqual(170) # ref top + ref height + offset 45 | ) 46 | 47 | it('place above', -> 48 | reference = @quill.addContainer('ql-reference') 49 | $(reference).css({ position: 'absolute', top: '350px', left: '200px', width: '100px', height: '50px' }) 50 | [left, top] = @tooltip.position(reference) 51 | expect(left).toEqual(150) 52 | expect(top).toEqual(230) # ref top - tooltip height - offset 53 | ) 54 | ) 55 | ) 56 | -------------------------------------------------------------------------------- /examples/scripts/advanced.coffee: -------------------------------------------------------------------------------- 1 | _ = Quill.require('lodash') 2 | 3 | basicEditor = new Quill('.basic-wrapper .editor-container', 4 | modules: 5 | authorship: { authorId: 'basic' } 6 | toolbar: { container: '.basic-wrapper .toolbar-container' } 7 | styles: false 8 | ) 9 | 10 | advancedEditor = new Quill('.advanced-wrapper .editor-container', 11 | modules: 12 | 'authorship': { authorId: 'advanced', enabled: true } 13 | 'toolbar': { container: '.advanced-wrapper .toolbar-container' } 14 | 'link-tooltip': true 15 | 'image-tooltip': true 16 | 'multi-cursor': true 17 | styles: false 18 | theme: 'snow' 19 | ) 20 | 21 | authorship = advancedEditor.getModule('authorship') 22 | authorship.addAuthor('basic', 'rgba(255,153,51,0.4)') 23 | 24 | cursorManager = advancedEditor.getModule('multi-cursor') 25 | cursorManager.setCursor('basic', 0, 'basic', 'rgba(255,153,51,0.9)') 26 | 27 | basicEditor.on('selection-change', (range) -> 28 | console.info 'basic', 'selection', range 29 | cursorManager.moveCursor('basic', range.end) if range? 30 | ) 31 | 32 | basicEditor.on('text-change', (delta, source) -> 33 | return if source == 'api' 34 | console.info 'basic', 'text', delta, source 35 | advancedEditor.updateContents(delta) 36 | sourceDelta = basicEditor.getContents() 37 | targetDelta = advancedEditor.getContents() 38 | console.assert(_.isEqual(sourceDelta, targetDelta), "Editor diversion!", sourceDelta.ops, targetDelta.ops) 39 | ) 40 | 41 | advancedEditor.on('selection-change', (range) -> 42 | console.info 'advanced', 'selection', range 43 | ) 44 | 45 | advancedEditor.on('text-change', (delta, source) -> 46 | return if source == 'api' 47 | console.info 'advanced', 'text', delta, source 48 | basicEditor.updateContents(delta) 49 | sourceDelta = advancedEditor.getContents() 50 | targetDelta = basicEditor.getContents() 51 | console.assert(_.isEqual(sourceDelta, targetDelta), "Editor diversion!", sourceDelta.ops, targetDelta.ops) 52 | ) 53 | -------------------------------------------------------------------------------- /src/modules/authorship.coffee: -------------------------------------------------------------------------------- 1 | Quill = require('../quill') 2 | _ = Quill.require('lodash') 3 | dom = Quill.require('dom') 4 | Delta = Quill.require('delta') 5 | 6 | 7 | class Authorship 8 | @DEFAULTS: 9 | authorId: null 10 | color: 'transparent' 11 | enabled: false 12 | 13 | constructor: (@quill, @options) -> 14 | this.attachButton(@options.button) if @options.button? 15 | this.enable() if @options.enabled 16 | @quill.addFormat('author', { class: 'author-' }) 17 | return unless @options.authorId? 18 | @quill.on(@quill.constructor.events.PRE_EVENT, (eventName, delta, origin) => 19 | if eventName == @quill.constructor.events.TEXT_CHANGE and origin == 'user' 20 | authorDelta = new Delta() 21 | authorFormat = { author: @options.authorId } 22 | _.each(delta.ops, (op) => 23 | return if op.delete? 24 | if op.insert? or (op.retain? and op.attributes?) 25 | # Add authorship to insert/format 26 | op.attributes or= {} 27 | op.attributes.author = @options.authorId 28 | # Apply authorship to our own editor 29 | authorDelta.retain(op.retain or op.insert.length or 1, authorFormat) 30 | else 31 | authorDelta.retain(op.retain) 32 | ) 33 | @quill.updateContents(authorDelta, Quill.sources.SILENT) 34 | ) 35 | this.addAuthor(@options.authorId, @options.color) 36 | 37 | addAuthor: (id, color) -> 38 | styles = {} 39 | styles[".authorship .author-#{id}"] = { "background-color": "#{color}" } 40 | @quill.theme.addStyles(styles) 41 | 42 | attachButton: (button) -> 43 | $button = dom(button) 44 | $button.on('click', => 45 | $button.toggleClass('ql-on') 46 | this.enable($dom.hasClass('ql-on')) 47 | ) 48 | 49 | enable: (enabled = true) -> 50 | dom(@quill.root).toggleClass('authorship', enabled) 51 | 52 | disable: -> 53 | this.enable(false) 54 | 55 | 56 | Quill.registerModule('authorship', Authorship) 57 | module.exports = Authorship 58 | -------------------------------------------------------------------------------- /test/unit/modules/paste-manager.coffee: -------------------------------------------------------------------------------- 1 | describe('PasteManager', -> 2 | it('_paste()', (done) -> 3 | container = $('#editor-container').get(0) 4 | container.innerHTML = ' 5 |
6 |
0123
7 |
' 8 | quill = new Quill(container.firstChild) 9 | pasteManager = quill.getModule('paste-manager') 10 | quill.setSelection(2, 2) 11 | pasteManager._paste() 12 | pasteManager.container.innerHTML = ' 13 | Pasted 14 |
Text
15 | ' 16 | quill.on(Quill.events.TEXT_CHANGE, (delta) -> 17 | expect(delta).toEqualDelta(new Quill.Delta().retain(2).insert('Pasted', { bold: true }).insert('\nText')) 18 | _.defer( -> 19 | range = quill.getSelection() 20 | expect(range.start).toEqual(13) 21 | expect(range.end).toEqual(13) 22 | done() 23 | ) 24 | ) 25 | ) 26 | 27 | it('optionally allows custom delta creation', (done) -> 28 | container = $('#editor-container').get(0) 29 | container.innerHTML = ' 30 |
31 |
0123
32 |
' 33 | quill = new Quill(container.firstChild, { 34 | modules: { 35 | 'paste-manager': { 36 | onConvert: -> 37 | new Quill.Delta().insert('something else', { 38 | italic: true 39 | }) 40 | } 41 | } 42 | }) 43 | pasteManager = quill.getModule('paste-manager') 44 | quill.setSelection(2, 2) 45 | pasteManager._paste() 46 | pasteManager.container.innerHTML = ' 47 | Pasted 48 |
Text
49 | ' 50 | quill.on(Quill.events.TEXT_CHANGE, (delta) -> 51 | expect(delta).toEqualDelta( 52 | new Quill.Delta() 53 | .retain(2) 54 | .insert('something else', { italic: true }) 55 | ) 56 | _.defer( -> 57 | range = quill.getSelection() 58 | expect(range.start).toEqual(16) 59 | expect(range.end).toEqual(16) 60 | done() 61 | ) 62 | ) 63 | ) 64 | ) 65 | -------------------------------------------------------------------------------- /config/grunt/protractor.coffee: -------------------------------------------------------------------------------- 1 | _ = require('lodash') 2 | browsers = require('../browsers') 3 | sauce = require('../sauce') 4 | 5 | module.exports = (grunt) -> 6 | remoteProtractor = _.reduce(browsers, (memo, config, browser) -> 7 | return _.reduce(['e2e', 'wd'], (memo, test) -> 8 | options = 9 | args: 10 | baseUrl: grunt.config('baseUrl') 11 | capabilities: 12 | name: "quill-#{test}" 13 | platform: config[0] 14 | browserName: config[1] 15 | version: config[2] 16 | build: sauce.build 17 | 'tunnel-identifier': sauce.tunnel 18 | sauceUser: sauce.username 19 | sauceKey: sauce.accessKey 20 | specs: ['test/wd/*.coffee'] 21 | jasmineNodeOpts: 22 | isVerbose: false 23 | options.args.exclude = ['test/wd/e2e.coffee'] if test == 'wd' 24 | memo["#{test}-#{browser}"] = { options: options } 25 | return memo 26 | , memo) 27 | , {}) 28 | 29 | grunt.config('protractor', _.extend(remoteProtractor, 30 | options: 31 | configFile: 'config/protractor.js' 32 | coverage: 33 | options: 34 | configFile: 'config/protractor.coverage.js' 35 | args: 36 | baseUrl: grunt.config('baseUrl') 37 | specs: ['test/wd/e2e.coffee'] 38 | e2e: 39 | options: 40 | args: 41 | baseUrl: grunt.config('baseUrl') 42 | specs: ['test/wd/e2e.coffee'] 43 | )) 44 | 45 | grunt.config('sauce_connect', 46 | quill: 47 | options: 48 | username: sauce.username 49 | accessKey: sauce.accessKey 50 | tunnelIdentifier: sauce.tunnel 51 | ) 52 | 53 | grunt.registerMultiTask('webdriver-manager', 'Protractor webdriver manager', -> 54 | grunt.util.spawn( 55 | cmd: './node_modules/protractor/bin/webdriver-manager' 56 | args: [this.target] 57 | opts: 58 | stdio: 'inherit' 59 | , this.async()) 60 | ) 61 | 62 | grunt.config('webdriver-manager', 63 | start: {} 64 | status: {} 65 | update: {} 66 | ) 67 | -------------------------------------------------------------------------------- /test/unit/lib/picker.coffee: -------------------------------------------------------------------------------- 1 | dom = Quill.Lib.DOM 2 | 3 | describe('Picker', -> 4 | beforeEach( -> 5 | @container = $('#test-container').html(Quill.Normalizer.stripWhitespace(' 6 | 11 | ')).get(0) 12 | @select = @container.querySelector('select') 13 | @picker = new Quill.Lib.Picker(@select) 14 | ) 15 | 16 | it('constructor', -> 17 | expect(@container.querySelector('.ql-picker').outerHTML).toEqualHTML(' 18 | 19 | Sans Serif 20 | 21 | Sans Serif 22 | Serif 23 | Monospace 24 | 25 | 26 | ') 27 | ) 28 | 29 | it('expand/close', (done) -> 30 | label = @container.querySelector('.ql-picker-label') 31 | picker = @container.querySelector('.ql-picker') 32 | dom(label).trigger('click') 33 | _.defer( -> 34 | expect(dom(picker).hasClass('ql-expanded')).toBe(true) 35 | dom(label).trigger('click') 36 | _.defer( -> 37 | expect(dom(picker).hasClass('ql-expanded')).toBe(false) 38 | done() 39 | ) 40 | ) 41 | ) 42 | 43 | it('select picker item', -> 44 | dom(@container.querySelector('.ql-picker-options').lastChild).trigger('click') 45 | expect(dom(@picker.label).text()).toEqual('Monospace') 46 | _.each(@container.querySelectorAll('.ql-picker-item'), (item, i) -> 47 | expect(dom(item).hasClass('ql-selected')).toBe(i == 2) 48 | ) 49 | ) 50 | 51 | it('select option', -> 52 | dom(@select).option('serif') 53 | expect(dom(@picker.label).text()).toEqual('Serif') 54 | _.each(@container.querySelectorAll('.ql-picker-item'), (item, i) -> 55 | expect(dom(item).hasClass('ql-selected')).toBe(i == 1) 56 | ) 57 | ) 58 | 59 | it('select option mixed', -> 60 | dom(@select).option('') 61 | expect(dom(@picker.label).text().trim()).toEqual('') 62 | _.each(@container.querySelectorAll('.ql-picker-item'), (item, i) -> 63 | expect(dom(item).hasClass('ql-selected')).toBe(false) 64 | ) 65 | ) 66 | ) 67 | -------------------------------------------------------------------------------- /src/lib/picker.coffee: -------------------------------------------------------------------------------- 1 | _ = require('lodash') 2 | dom = require('./dom') 3 | 4 | 5 | class Picker 6 | @TEMPLATE: '' 7 | 8 | constructor: (@select) -> 9 | @container = document.createElement('span') 10 | this.buildPicker() 11 | dom(@container).addClass('ql-picker') 12 | @select.style.display = 'none' 13 | @select.parentNode.insertBefore(@container, @select) 14 | dom(document).on('click', => 15 | this.close() 16 | return true 17 | ) 18 | dom(@label).on('click', => 19 | _.defer( => 20 | dom(@container).toggleClass('ql-expanded') 21 | ) 22 | return false 23 | ) 24 | dom(@select).on('change', => 25 | if @select.selectedIndex > -1 26 | item = @container.querySelectorAll('.ql-picker-item')[@select.selectedIndex] 27 | option = @select.options[@select.selectedIndex] 28 | this.selectItem(item, false) 29 | dom(@label).toggleClass('ql-active', option != dom(@select).default()) 30 | ) 31 | 32 | buildItem: (picker, option, index) -> 33 | item = document.createElement('span') 34 | item.setAttribute('data-value', option.getAttribute('value')) 35 | dom(item).addClass('ql-picker-item').text(dom(option).text()).on('click', => 36 | this.selectItem(item, true) 37 | this.close() 38 | ) 39 | this.selectItem(item, false) if @select.selectedIndex == index 40 | return item 41 | 42 | buildPicker: -> 43 | _.each(dom(@select).attributes(), (value, name) => 44 | @container.setAttribute(name, value) 45 | ) 46 | @container.innerHTML = Picker.TEMPLATE 47 | @label = @container.querySelector('.ql-picker-label') 48 | picker = @container.querySelector('.ql-picker-options') 49 | _.each(@select.options, (option, i) => 50 | item = this.buildItem(picker, option, i) 51 | picker.appendChild(item) 52 | ) 53 | 54 | close: -> 55 | dom(@container).removeClass('ql-expanded') 56 | 57 | selectItem: (item, trigger) -> 58 | selected = @container.querySelector('.ql-selected') 59 | dom(selected).removeClass('ql-selected') if selected? 60 | if item? 61 | value = item.getAttribute('data-value') 62 | dom(item).addClass('ql-selected') 63 | dom(@label).text(dom(item).text()) 64 | dom(@select).option(value, trigger) 65 | @label.setAttribute('data-value', value) 66 | else 67 | @label.innerHTML = ' ' 68 | @label.removeAttribute('data-value') 69 | 70 | 71 | module.exports = Picker 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "democracyos-quill", 3 | "version": "0.20.1", 4 | "description": "Cross browser rich text editor", 5 | "author": "Jason Chen ", 6 | "homepage": "http://quilljs.com", 7 | "contributors": [ 8 | "Byron Milligan ", 9 | "Keegan Poppen " 10 | ], 11 | "main": "index.js", 12 | "devDependencies": { 13 | "async": "~1.2.1", 14 | "browserify": "~10.2.1", 15 | "coffee-script": "~1.9.0", 16 | "coffeeify": "~1.1.0", 17 | "derequire": "~2.0.0", 18 | "eventemitter2": "~0.4.14", 19 | "glob": "~5.0.3", 20 | "grunt": "~0.4.5", 21 | "grunt-browserify": "~3.8.0", 22 | "grunt-cli": "~0.1.13", 23 | "grunt-contrib-clean": "~0.6.0", 24 | "grunt-contrib-coffee": "~0.13.0", 25 | "grunt-contrib-compress": "~0.13.0", 26 | "grunt-contrib-concat": "~0.5.1", 27 | "grunt-contrib-connect": "~0.10.1", 28 | "grunt-contrib-copy": "~0.8.0", 29 | "grunt-contrib-stylus": "~0.21.0", 30 | "grunt-contrib-uglify": "~0.9.1", 31 | "grunt-karma": "~0.11.0", 32 | "grunt-lodash": "~0.4.0", 33 | "grunt-protractor-runner": "~2.0.0", 34 | "grunt-sauce-connect-launcher": "~0.3.0", 35 | "harp": "~0.17.0", 36 | "http-proxy": "~1.11.1", 37 | "istanbul": "~0.3.5", 38 | "jasmine-core": "~2.3.4", 39 | "jquery": "~2.1.3", 40 | "karma": "~0.12.31", 41 | "karma-chrome-launcher": "~0.1.7", 42 | "karma-coffee-preprocessor": "~0.2.1", 43 | "karma-coverage": "~0.4.2", 44 | "karma-firefox-launcher": "~0.1.4", 45 | "karma-html2js-preprocessor": "~0.1.0", 46 | "karma-jasmine": "~0.3.5", 47 | "karma-safari-launcher": "~0.1.1", 48 | "karma-sauce-launcher": "~0.2.10", 49 | "load-grunt-tasks": "~3.2.0", 50 | "lodash": "~3.9.1", 51 | "lodash-cli": "~3.9.3", 52 | "protractor": "~2.1.0", 53 | "rich-text": "~2.1.0", 54 | "stylify": "~1.0.0", 55 | "stylus": "~0.51.1", 56 | "through": "~2.3.7", 57 | "watchify": "~3.2.1" 58 | }, 59 | "browser": { 60 | "lodash": ".build/lodash.js" 61 | }, 62 | "engines": { 63 | "node": ">=0.10" 64 | }, 65 | "license": "BSD-3-Clause", 66 | "repository": { 67 | "type": "git", 68 | "url": "https://github.com/quilljs/quill" 69 | }, 70 | "bugs": { 71 | "url": "https://github.com/quilljs/quill/issues" 72 | }, 73 | "scripts": { 74 | "prepublish": "grunt dist", 75 | "postpublish": "grunt clean:all", 76 | "test": "grunt test" 77 | }, 78 | "keywords": [ 79 | "editor", 80 | "rich text", 81 | "wysiwyg" 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /test/fixtures/e2e.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Quill End to End Test 6 | 7 | 22 | 23 | 24 |
25 |
26 | 32 | 38 | 39 |
40 |
41 |
42 |
43 |
44 | Range:  45 | Initial 46 | Initial 47 |
48 |
49 | Delta:  50 | Initial 51 |
52 |
53 | 54 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /src/themes/snow/index.coffee: -------------------------------------------------------------------------------- 1 | _ = require('lodash') 2 | ColorPicker = require('../../lib/color-picker') 3 | BaseTheme = require('../base') 4 | dom = require('../../lib/dom') 5 | Picker = require('../../lib/picker') 6 | 7 | 8 | class SnowTheme extends BaseTheme 9 | @COLORS: [ 10 | "#000000", "#e60000", "#ff9900", "#ffff00", "#008A00", "#0066cc", "#9933ff" 11 | "#ffffff", "#facccc", "#ffebcc", "#ffffcc", "#cce8cc", "#cce0f5", "#ebd6ff" 12 | "#bbbbbb", "#f06666", "#ffc266", "#ffff66", "#66b966", "#66a3e0", "#c285ff" 13 | "#888888", "#a10000", "#b26b00", "#b2b200", "#006100", "#0047b2", "#6b24b2" 14 | "#444444", "#5c0000", "#663d00", "#666600", "#003700", "#002966", "#3d1466" 15 | ] 16 | 17 | @OPTIONS: 18 | 'multi-cursor': 19 | template: 20 | ' 21 | 22 | 23 | 24 | 25 | ' 26 | 27 | constructor: (@quill, @options) -> 28 | super 29 | dom(@quill.container).addClass('ql-snow') 30 | @pickers = [] 31 | @quill.on(@quill.constructor.events.SELECTION_CHANGE, (range) => 32 | _.invoke(@pickers, 'close') if range? 33 | ) 34 | @quill.onModuleLoad('multi-cursor', _.bind(this.extendMultiCursor, this)) 35 | @quill.onModuleLoad('toolbar', _.bind(this.extendToolbar, this)) 36 | 37 | extendMultiCursor: (module) -> 38 | module.on(module.constructor.events.CURSOR_ADDED, (cursor) -> 39 | bottomTriangle = cursor.elem.querySelector('.cursor-triangle.bottom') 40 | topTriangle = cursor.elem.querySelector('.cursor-triangle.top') 41 | bottomTriangle.style.borderTopColor = topTriangle.style.borderBottomColor = cursor.color 42 | ) 43 | 44 | extendToolbar: (module) -> 45 | dom(module.container).addClass('ql-snow') 46 | _.each(['color', 'background', 'font', 'size', 'align'], (format) => 47 | select = module.container.querySelector(".ql-#{format}") 48 | return unless select? 49 | switch format 50 | when 'font', 'size', 'align' 51 | picker = new Picker(select) 52 | when 'color', 'background' 53 | picker = new ColorPicker(select) 54 | _.each(picker.container.querySelectorAll('.ql-picker-item'), (item, i) -> 55 | dom(item).addClass('ql-primary-color') if i < 7 56 | ) 57 | @pickers.push(picker) if picker? 58 | ) 59 | _.each(dom(module.container).textNodes(), (node) -> 60 | if dom(node).text().trim().length == 0 61 | dom(node).remove() 62 | ) 63 | 64 | 65 | module.exports = SnowTheme 66 | -------------------------------------------------------------------------------- /src/modules/tooltip.coffee: -------------------------------------------------------------------------------- 1 | Quill = require('../quill') 2 | _ = Quill.require('lodash') 3 | dom = Quill.require('dom') 4 | 5 | 6 | class Tooltip 7 | @DEFAULTS: 8 | offset: 10 9 | template: '' 10 | 11 | @HIDE_MARGIN = '-10000px' 12 | 13 | constructor: (@quill, @options) -> 14 | @container = @quill.addContainer('ql-tooltip') 15 | @container.innerHTML = @options.template 16 | this.hide() 17 | @quill.on(@quill.constructor.events.TEXT_CHANGE, (delta, source) => 18 | if @container.style.left != Tooltip.HIDE_MARGIN 19 | @range = null 20 | this.hide() 21 | ) 22 | 23 | initTextbox: (textbox, enterCallback, escapeCallback) -> 24 | dom(textbox).on('keydown', (event) => 25 | switch event.which 26 | when dom.KEYS.ENTER 27 | event.preventDefault() 28 | enterCallback.call(this) 29 | when dom.KEYS.ESCAPE 30 | event.preventDefault() 31 | escapeCallback.call(this) 32 | else 33 | return true 34 | ) 35 | 36 | hide: -> 37 | @container.style.left = Tooltip.HIDE_MARGIN 38 | @quill.setSelection(@range) if @range 39 | @range = null 40 | 41 | position: (reference) -> 42 | if reference? 43 | # Place tooltip under reference centered 44 | # reference might be selection range so must use getBoundingClientRect() 45 | referenceBounds = reference.getBoundingClientRect() 46 | parentBounds = @quill.container.getBoundingClientRect() 47 | offsetLeft = referenceBounds.left - parentBounds.left 48 | offsetTop = referenceBounds.top - parentBounds.top 49 | offsetBottom = referenceBounds.bottom - parentBounds.bottom 50 | left = offsetLeft + referenceBounds.width/2 - @container.offsetWidth/2 51 | top = offsetTop + referenceBounds.height + @options.offset 52 | if top + @container.offsetHeight > @quill.container.offsetHeight 53 | top = offsetTop - @container.offsetHeight - @options.offset 54 | left = Math.max(0, Math.min(left, @quill.container.offsetWidth - @container.offsetWidth)) 55 | top = Math.max(0, Math.min(top, @quill.container.offsetHeight - @container.offsetHeight)) 56 | else 57 | # Place tooltip in middle of editor viewport 58 | left = @quill.container.offsetWidth/2 - @container.offsetWidth/2 59 | top = @quill.container.offsetHeight/2 - @container.offsetHeight/2 60 | top += @quill.container.scrollTop 61 | return [left, top] 62 | 63 | show: (reference) -> 64 | @range = @quill.getSelection() 65 | [left, top] = this.position(reference) 66 | @container.style.left = "#{left}px" 67 | @container.style.top = "#{top}px" 68 | @container.focus() 69 | 70 | 71 | Quill.registerModule('tooltip', Tooltip) 72 | module.exports = Tooltip 73 | -------------------------------------------------------------------------------- /test/helpers/matchers.coffee: -------------------------------------------------------------------------------- 1 | dom = Quill.Lib.DOM 2 | 3 | 4 | compareNodes = (node1, node2, ignoredAttributes = []) -> 5 | return false unless node1.nodeType == node2.nodeType 6 | if dom(node1).isElement() 7 | return false unless dom(node2).isElement() 8 | return false unless node1.tagName == node2.tagName 9 | [attr1, attr2] = _.map([node1, node2], (node) -> 10 | attr = dom(node).attributes() 11 | _.each(ignoredAttributes, (name) -> 12 | delete attr[name] 13 | ) 14 | attr.style = attr.style.trim() if attr.style? 15 | return attr 16 | ) 17 | return false unless _.isEqual(attr1, attr2) 18 | return false unless node1.childNodes.length == node2.childNodes.length 19 | equal = true 20 | _.each(dom(node1).childNodes(), (child1, i) -> 21 | if !compareNodes(child1, node2.childNodes[i], ignoredAttributes) 22 | equal = false 23 | return false 24 | ) 25 | return equal 26 | else 27 | return dom(node1).text() == dom(node2).text() 28 | 29 | 30 | beforeEach( -> 31 | matchers = 32 | toEqualDelta: -> 33 | return { 34 | compare: (actual, expected) -> 35 | pass = _.isEqual(actual, expected) 36 | if pass 37 | message = 'Deltas equal' 38 | else 39 | message = "Deltas unequal: \n#{jasmine.pp(actual)}\n\n#{jasmine.pp(expected)}\n" 40 | return { message: message, pass: pass } 41 | } 42 | 43 | toEqualHTML: -> 44 | return { 45 | compare: (actual, expected, ignoreClassId) -> 46 | [div1, div2] = _.map([actual, expected], (html) -> 47 | html = html.join('') if Array.isArray(html) 48 | html = html.innerHTML if _.isElement(html) 49 | div = document.createElement('div') 50 | div.innerHTML = Quill.Normalizer.stripWhitespace(html) 51 | return div 52 | ) 53 | ignoredAttributes = if ignoreClassId then ['class', 'id'] else [] 54 | ignoredAttributes = ignoredAttributes.concat(['width', 'height']) # IE adds automatically 55 | pass = compareNodes(div1, div2, ignoredAttributes) 56 | if pass 57 | message = 'HTMLs equal' 58 | else 59 | message = "HTMLs unequal: \n#{jasmine.pp(div1.innerHTML)}\n\n#{jasmine.pp(div2.innerHTML)}\n" 60 | return { message: message, pass: pass } 61 | } 62 | 63 | toBeApproximately: -> 64 | return { 65 | compare: (actual, expected, tolerance) -> 66 | pass = Math.abs(actual - expected) <= tolerance 67 | return { 68 | pass: pass 69 | message: "#{actual} is #{if pass then '' else 'not'} approximately #{expected}" 70 | } 71 | } 72 | 73 | jasmine.addMatchers(matchers) 74 | ) 75 | -------------------------------------------------------------------------------- /config/grunt/dist.coffee: -------------------------------------------------------------------------------- 1 | derequire = require('derequire/plugin') 2 | fs = require('fs') 3 | through = require('through') 4 | 5 | 6 | versionify = (file) -> 7 | data = '' 8 | write = (buf) -> 9 | data += buf 10 | end = -> 11 | if file.indexOf('package.json') > -1 12 | version = JSON.parse(data).version 13 | this.queue(JSON.stringify({ version: version })) 14 | else 15 | this.queue(data) 16 | this.queue(null) 17 | return through(write, end) 18 | 19 | 20 | module.exports = (grunt) -> 21 | grunt.config('browserify', 22 | quill: 23 | options: 24 | browserifyOptions: 25 | extensions: ['.js', '.coffee'] 26 | standalone: 'Quill' 27 | transform: ['coffeeify', 'stylify', versionify] 28 | plugin: [derequire] 29 | files: 30 | 'dist/quill.js': ['src/index.coffee'] 31 | ) 32 | 33 | grunt.config('clean', 34 | all: ['.build', 'dist'] 35 | coverage: ['lib', 'src/**/*.js'] 36 | ) 37 | 38 | grunt.config('coffee', 39 | quill: 40 | options: 41 | bare: true 42 | cwd: 'src/' 43 | dest: 'lib/' 44 | expand: true 45 | src: ['**/*.coffee'] 46 | ext: '.js' 47 | ) 48 | 49 | grunt.config('concat', 50 | options: 51 | banner: 52 | '/*! Quill Editor v<%= pkg.version %>\n' + 53 | ' * https://quilljs.com/\n' + 54 | ' * Copyright (c) 2014, Jason Chen\n' + 55 | ' * Copyright (c) 2013, salesforce.com\n' + 56 | ' */\n' 57 | quill: 58 | files: 59 | 'dist/quill.js': ['dist/quill.js'] 60 | 'dist/quill.min.js': ['dist/quill.min.js'] 61 | 'dist/quill.base.css': ['dist/quill.base.css'] 62 | 'dist/quill.snow.css': ['dist/quill.snow.css'] 63 | ) 64 | 65 | grunt.config('lodash', 66 | options: 67 | modifier: 'modern' 68 | include: [ 69 | 'difference', 'intersection', 'last' 70 | 'all', 'each', 'find', 'invoke', 'map', 'reduce', 'partition', 71 | 'bind', 'defer', 'partial' 72 | 'clone', 'extend', 'defaults', 'omit', 'values' 73 | 'isElement', 'isEqual', 'isFunction', 'isNumber', 'isObject', 'isString' 74 | 'uniqueId' 75 | ] 76 | flags: ['development'] 77 | target: 78 | dest: '.build/lodash.js' 79 | ) 80 | 81 | grunt.config('stylus', 82 | options: 83 | compress: false 84 | themes: 85 | options: 86 | urlfunc: 'url' 87 | files: [{ 88 | expand: true 89 | ext: '.css' 90 | flatten: true 91 | src: 'src/themes/*/*.styl' 92 | rename: (dest, src) -> 93 | return "dist/quill.#{src}" 94 | }] 95 | ) 96 | 97 | grunt.config('uglify', 98 | quill: 99 | files: { 'dist/quill.min.js': ['dist/quill.js'] } 100 | ) 101 | 102 | -------------------------------------------------------------------------------- /src/modules/image-tooltip.coffee: -------------------------------------------------------------------------------- 1 | Quill = require('../quill') 2 | Tooltip = require('./tooltip') 3 | _ = Quill.require('lodash') 4 | dom = Quill.require('dom') 5 | Delta = Quill.require('delta') 6 | Range = Quill.require('range') 7 | 8 | 9 | class ImageTooltip extends Tooltip 10 | @DEFAULTS: 11 | template: 12 | ' 13 |
14 | Preview 15 |
16 | Cancel 17 | Insert' 18 | 19 | constructor: (@quill, @options) -> 20 | @options = _.defaults(@options, Tooltip.DEFAULTS) 21 | super(@quill, @options) 22 | @preview = @container.querySelector('.preview') 23 | @textbox = @container.querySelector('.input') 24 | dom(@container).addClass('ql-image-tooltip') 25 | this.initListeners() 26 | 27 | initListeners: -> 28 | dom(@quill.root).on('focus', _.bind(this.hide, this)) 29 | dom(@container.querySelector('.insert')).on('click', _.bind(this.insertImage, this)) 30 | dom(@container.querySelector('.cancel')).on('click', _.bind(this.hide, this)) 31 | dom(@textbox).on('input', _.bind(this._preview, this)) 32 | this.initTextbox(@textbox, this.insertImage, this.hide) 33 | @quill.onModuleLoad('toolbar', (toolbar) => 34 | @toolbar = toolbar 35 | toolbar.initFormat('image', _.bind(this._onToolbar, this)) 36 | ) 37 | 38 | insertImage: -> 39 | url = this._normalizeURL(@textbox.value) 40 | @range = new Range(0, 0) unless @range? # If we lost the selection somehow, just put image at beginning of document 41 | if @range 42 | @preview.innerHTML = 'Preview' 43 | @textbox.value = '' 44 | index = @range.end 45 | @quill.insertEmbed(index, 'image', url, 'user') 46 | @quill.setSelection(index + 1, index + 1) 47 | this.hide() 48 | 49 | _onToolbar: (range, value) -> 50 | if value 51 | @textbox.value = 'http://' unless @textbox.value 52 | this.show() 53 | @textbox.focus() 54 | _.defer( => 55 | @textbox.setSelectionRange(@textbox.value.length, @textbox.value.length) 56 | ) 57 | else 58 | @quill.deleteText(range, 'user') 59 | @toolbar.setActive('image', false) 60 | 61 | _preview: -> 62 | return unless this._matchImageURL(@textbox.value) 63 | if @preview.firstChild.tagName == 'IMG' 64 | @preview.firstChild.setAttribute('src', @textbox.value) 65 | else 66 | img = document.createElement('img') 67 | img.setAttribute('src', @textbox.value) 68 | @preview.replaceChild(img, @preview.firstChild) 69 | 70 | _matchImageURL: (url) -> 71 | return /^https?:\/\/.+\.(jpe?g|gif|png)$/.test(url) 72 | 73 | _normalizeURL: (url) -> 74 | # For now identical to link-tooltip but will change when we allow data uri 75 | url = 'http://' + url unless /^https?:\/\//.test(url) 76 | return url 77 | 78 | 79 | Quill.registerModule('image-tooltip', ImageTooltip) 80 | module.exports = ImageTooltip 81 | -------------------------------------------------------------------------------- /examples/index.jade: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title Quill Editor Demo 5 | link(rel='stylesheet', type='text/css', href='styles/style.css') 6 | meta(charset='utf-8') 7 | 8 | body 9 | #content-container 10 | #editor-wrapper 11 | #formatting-container 12 | select.ql-font(title='Font') 13 | option(value='sans-serif', selected) Sans Serif 14 | option(value='Georgia, serif') Serif 15 | option(value='Monaco, \'Courier New\', monospace') Monospace 16 | select.ql-size(title='Size') 17 | option(value='10px') Small 18 | option(value='13px', selected) Normal 19 | option(value='18px') Large 20 | option(value='32px') Huge 21 | select.ql-color(title='Text Color') 22 | option(value='rgb(255, 255, 255)') White 23 | option(value='rgb(0, 0, 0)', selected) Black 24 | option(value='rgb(255, 0, 0)') Red 25 | option(value='rgb(0, 0, 255)') Blue 26 | option(value='rgb(0, 255, 0)') Lime 27 | option(value='rgb(0, 128, 128)') Teal 28 | option(value='rgb(255, 0, 255)') Magenta 29 | option(value='rgb(255, 255, 0)') Yellow 30 | select.ql-background(title='Background Color') 31 | option(value='rgb(255, 255, 255)', selected) White 32 | option(value='rgb(0, 0, 0)') Black 33 | option(value='rgb(255, 0, 0)') Red 34 | option(value='rgb(0, 0, 255)') Blue 35 | option(value='rgb(0, 255, 0)') Lime 36 | option(value='rgb(0, 128, 128)') Teal 37 | option(value='rgb(255, 0, 255)') Magenta 38 | option(value='rgb(255, 255, 0)') Yellow 39 | select.ql-align(title='Text Alignment') 40 | option(value='left', selected) Left 41 | option(value='center') Center 42 | option(value='right') Right 43 | option(value='justify') Justify 44 | button.ql-format-button.ql-bold(title='Bold') Bold 45 | button.ql-format-button.ql-italic(title='Italic') Italic 46 | button.ql-format-button.ql-underline(title='Underline') Under 47 | button.ql-format-button.ql-strike(title='Strikethrough') Strike 48 | button.ql-format-button.ql-link(title='Link') Link 49 | button.ql-format-button.ql-image(title='Image') Image 50 | button.ql-format-button.ql-video(title='Video') Video 51 | button.ql-format-button.ql-bullet(title='Bullet') Bullet 52 | button.ql-format-button.ql-list(title='List') List 53 | #editor-container 54 | 55 | script(type='text/javascript', src='../quill.js') 56 | script(type='text/javascript'). 57 | var editor = new Quill('#editor-container', { 58 | modules: { 59 | 'toolbar': { container: '#formatting-container' }, 60 | 'link-tooltip': true, 61 | 'image-tooltip': true, 62 | 'video-tooltip': true 63 | } 64 | }); 65 | editor.on('selection-change', function(range) { 66 | console.log('selection-change', range) 67 | }); 68 | editor.on('text-change', function(delta, source) { 69 | console.log('text-change', delta, source) 70 | }); 71 | -------------------------------------------------------------------------------- /test/unit/core/leaf.coffee: -------------------------------------------------------------------------------- 1 | dom = Quill.Lib.DOM 2 | 3 | describe('Leaf', -> 4 | beforeEach( -> 5 | @container = $('#test-container').html('').get(0) 6 | ) 7 | 8 | describe('constructor', -> 9 | tests = 10 | 'image': 11 | html: '' 12 | text: dom.EMBED_TEXT 13 | 'break': 14 | html: '
' 15 | text: '' 16 | 'empty element': 17 | html: '' 18 | text: '' 19 | 'text': 20 | html: 'Text' 21 | text: 'Text' 22 | 23 | _.each(tests, (test, name) -> 24 | it(name, -> 25 | @container.innerHTML = test.html 26 | leaf = new Quill.Leaf(@container.firstChild, {}) 27 | expect(leaf.text).toEqual(test.text) 28 | ) 29 | ) 30 | ) 31 | 32 | describe('isLeafNode()', -> 33 | tests = 34 | 'text node': 35 | html: 'Test' 36 | expected: true 37 | 'empty element': 38 | html: '' 39 | expected: true 40 | 'break': 41 | html: '
' 42 | expected: true 43 | 'image': 44 | html: '' 45 | expected: true 46 | 'element with element child': 47 | html: '' 48 | expected: false 49 | 'element with text child': 50 | html: 'Test' 51 | expected: false 52 | 53 | _.each(tests, (test, name) -> 54 | it(name, -> 55 | @container.innerHTML = test.html 56 | expect(Quill.Leaf.isLeafNode(@container.firstChild)).toBe(test.expected) 57 | ) 58 | ) 59 | ) 60 | 61 | describe('deleteText()', -> 62 | beforeEach( -> 63 | @container.innerHTML = 'Test' 64 | @leaf = new Quill.Leaf(@container.firstChild, {}) 65 | ) 66 | 67 | tests = 68 | 'remove middle': 69 | expected: 'Tt' 70 | offset: 1, length: 2 71 | 'remove till end': 72 | expected: 'Te' 73 | offset: 2, length: 2 74 | 'remove all': 75 | expected: '' 76 | offset: 0, length: 4 77 | 78 | _.each(tests, (test, name) -> 79 | it(name, -> 80 | @leaf.deleteText(test.offset, test.length) 81 | expect(@leaf.text).toEqualHTML(test.expected) 82 | expect(dom(@leaf.node).text()).toEqualHTML(test.expected) 83 | ) 84 | ) 85 | ) 86 | 87 | describe('deleteText() with embed tags', -> 88 | it('removes the embed tag and replaces it with an empty text node', -> 89 | @container.innerHTML = '' 90 | leaf = new Quill.Leaf(@container.firstChild, {}) 91 | 92 | leaf.deleteText(0, 1) 93 | 94 | expect(leaf.text).toEqualHTML('') 95 | expect(dom(leaf.node).text()).toEqualHTML('') 96 | ) 97 | ) 98 | 99 | describe('insertText()', -> 100 | tests = 101 | 'element with text node': 102 | initial: 'Test' 103 | expected: 'Te|st' 104 | text: 'Test' 105 | 'element without text node': 106 | initial: '' 107 | expected: '|' 108 | 'break': 109 | initial: '
' 110 | expected: '|' 111 | 112 | _.each(tests, (test, name) -> 113 | it(name, -> 114 | @container.innerHTML = test.initial 115 | leaf = new Quill.Leaf(@container.firstChild, {}) 116 | text = test.text or '' 117 | length = text.length 118 | expect(leaf.text).toEqual(text) 119 | expect(leaf.length).toEqual(length) 120 | leaf.insertText(length/2, '|') 121 | expect(@container).toEqualHTML(test.expected) 122 | expect(leaf.text).toEqual(dom(leaf.node).text()) 123 | expect(leaf.length).toEqual(length + 1) 124 | ) 125 | ) 126 | ) 127 | ) 128 | -------------------------------------------------------------------------------- /src/modules/undo-manager.coffee: -------------------------------------------------------------------------------- 1 | Quill = require('../quill') 2 | _ = Quill.require('lodash') 3 | Delta = Quill.require('delta') 4 | 5 | 6 | class UndoManager 7 | @DEFAULTS: 8 | delay: 1000 9 | maxStack: 100 10 | userOnly: false 11 | 12 | @hotkeys: 13 | UNDO: { key: 'Z', metaKey: true } 14 | REDO: { key: 'Z', metaKey: true, shiftKey: true } 15 | 16 | constructor: (@quill, @options = {}) -> 17 | @lastRecorded = 0 18 | @ignoreChange = false 19 | this.clear() 20 | this.initListeners() 21 | 22 | initListeners: -> 23 | @quill.onModuleLoad('keyboard', (keyboard) => 24 | keyboard.addHotkey(UndoManager.hotkeys.UNDO, => 25 | @quill.editor.checkUpdate() 26 | this.undo() 27 | return false 28 | ) 29 | redoKey = [UndoManager.hotkeys.REDO] 30 | if (navigator.platform.indexOf('Win') > -1) 31 | redoKey.push({ key: 'Y', metaKey: true }) 32 | keyboard.addHotkey(redoKey, => 33 | @quill.editor.checkUpdate() 34 | this.redo() 35 | return false 36 | ) 37 | ) 38 | @quill.on(@quill.constructor.events.TEXT_CHANGE, (delta, source) => 39 | return if @ignoreChange 40 | if !@options.userOnly or source == Quill.sources.USER 41 | this.record(delta, @oldDelta) 42 | else 43 | this._transform(delta) 44 | @oldDelta = @quill.getContents() 45 | ) 46 | 47 | clear: -> 48 | @stack = 49 | undo: [] 50 | redo: [] 51 | @oldDelta = @quill.getContents() 52 | 53 | record: (changeDelta, oldDelta) -> 54 | return unless changeDelta.ops.length > 0 55 | @stack.redo = [] 56 | try 57 | undoDelta = @quill.getContents().diff(@oldDelta) 58 | timestamp = new Date().getTime() 59 | if @lastRecorded + @options.delay > timestamp and @stack.undo.length > 0 60 | change = @stack.undo.pop() 61 | undoDelta = undoDelta.compose(change.undo) 62 | changeDelta = change.redo.compose(changeDelta) 63 | else 64 | @lastRecorded = timestamp 65 | @stack.undo.push({ 66 | redo: changeDelta 67 | undo: undoDelta 68 | }) 69 | @stack.undo.unshift() if @stack.undo.length > @options.maxStack 70 | catch ignored 71 | console.warn('Could not record change... clearing undo stack.') 72 | this.clear() 73 | 74 | redo: -> 75 | this._change('redo', 'undo') 76 | 77 | undo: -> 78 | this._change('undo', 'redo') 79 | 80 | _getLastChangeIndex: (delta) -> 81 | lastIndex = 0 82 | index = 0 83 | _.each(delta.ops, (op) -> 84 | if op.insert? 85 | lastIndex = Math.max(index + (op.insert.length or 1), lastIndex) 86 | else if op.delete? 87 | lastIndex = Math.max(index, lastIndex) 88 | else if op.retain? 89 | if op.attributes? 90 | lastIndex = Math.max(index + op.retain, lastIndex) 91 | index += op.retain 92 | ) 93 | return lastIndex 94 | 95 | _change: (source, dest) -> 96 | if @stack[source].length > 0 97 | change = @stack[source].pop() 98 | @lastRecorded = 0 99 | @ignoreChange = true 100 | @quill.updateContents(change[source], Quill.sources.USER) 101 | @ignoreChange = false 102 | index = this._getLastChangeIndex(change[source]) 103 | @quill.setSelection(index, index) 104 | @oldDelta = @quill.getContents() 105 | @stack[dest].push(change) 106 | 107 | _transform: (delta) -> 108 | @oldDelta = delta.transform(@oldDelta, true) 109 | for change in @stack.undo 110 | change.undo = delta.transform(change.undo, true) 111 | change.redo = delta.transform(change.redo, true) 112 | for change in @stack.redo 113 | change.undo = delta.transform(change.undo, true) 114 | change.redo = delta.transform(change.redo, true) 115 | 116 | 117 | Quill.registerModule('undo-manager', UndoManager) 118 | module.exports = UndoManager 119 | -------------------------------------------------------------------------------- /config/grunt/server.coffee: -------------------------------------------------------------------------------- 1 | _ = require('lodash') 2 | browserify = require('browserify') 3 | coffeeify = require('coffeeify') 4 | fs = require('fs') 5 | harp = require('harp') 6 | proxy = require('http-proxy') 7 | stylify = require('stylify') 8 | stylus = require('stylus') 9 | watchify = require('watchify') 10 | 11 | 12 | FAVICON = new Buffer('iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAACf0lEQVR42r2XS2gTURSG04K2VReilorEECVKiJk8EYuurIgPEFddKW4El1J3FbRUEOzKKuhKdy4Uql0H0UVxoYIKkoWCrxaKz1qKTayNYv0O3IEhzNzecSYz8HNnJpPz3XPm3HPuxGIRHNlstqdQKBwul8tDpVLpDprg/BV63hJgPB7vAngU0HX0BtCSh76FCs7n89sBjqJZDfS343whFHCxWNyEsZvojwb8jok9YKw77tUDwzF6CtW8wPw2zwQvMN51+f3jf4MzmcwaDIxpPBb4S8Zd6JHHM9UgIa/q4OgqObFDQq+Z4G3fcLJ77TLwBSZ4gueSACaXmeRZv2FfidGHGo9+MO7N5XJbDOBLRKjoN+Eu69Y0Xu80haO3mGzzAz+I/np4Pk3YMwLnesoALv8ZMIYnk8lOTTLNCNyyrK2mcPQerTKeAA8PenhRQ70+4T95Vbv9rvcZF0MNPD/EmNDBmeB3qYDSF7geAb7fb+KdcTMM/CTjBtXVnMAv6BY6ThfcHLjUYvS1i1ejKjJPm+7PomP8rT2UJiPvygVekXbL+X3Ne37BcwfCaDRXmuCT6XR6vWwqDJdaRVZQkAl8cPZxIrKHe9cM4Z9RX5DwF5qMnlcygY+TpN1Bwz/sMPpEst6rEjqTUBpRKAmIscfK6C/G07LuNfCG5AsrY10ocGr6ahsoPZtxzsPjRcYbUglD3VwSxn12b0efXMBfVWdMtGRbLXs4j7o/Ltttrle07CNCdT57xyNldkSWUyqV6ojiI6YN2D17wyi5EIvyIPTnFHyOUG+LFA60X9a50pGo4ZZ8QCjvL0Ud9m675kvzCK2V+qh4F9Ez+Xqhkm2MRXz8AzAAXszjgRshAAAAAElFTkSuQmCC', 'base64') 13 | 14 | 15 | browserifyOps = 16 | cache: {} 17 | extensions: ['.js', '.coffee'] 18 | fullPaths: true 19 | packageCache: {} 20 | standalone: 'Quill' 21 | 22 | 23 | bundle = (watcher) -> 24 | return watcher.bundle().on('error', (err) -> 25 | console.error(err.name, err.message) 26 | ) 27 | 28 | serve = (connect, req, res, next) -> 29 | watchers = connect.watchers 30 | if req.url.indexOf('/karma') == 0 or req.url.indexOf('/base') == 0 31 | return connect.karmaProxy.web(req, res) 32 | url = if req.url.indexOf('/develop') == 0 then req.url.slice('/develop'.length) else req.url 33 | switch url 34 | when '/quill.js' 35 | res.setHeader('Content-Type', 'application/javascript') 36 | bundle(watchers['src']).pipe(res) 37 | when '/test/quill.js' 38 | res.setHeader('Content-Type', 'application/javascript') 39 | bundle(watchers['test']).pipe(res) 40 | when '/quill.snow.css', '/quill.base.css' 41 | theme = url.slice(7, 11) 42 | res.setHeader('Content-Type', 'text/css') 43 | fs.readFile("./src/themes/#{theme}/#{theme}.styl", (err, data) -> 44 | s = stylus(data.toString()) 45 | s.include("./src/themes/#{theme}") 46 | s.define('url', stylus.url()) 47 | s.render((err, css) -> 48 | console.error(err.name, err.message) if err? 49 | res.end(css) 50 | ) 51 | ) 52 | when '/favicon.ico' 53 | res.setHeader('Content-Type', 'image/png') 54 | res.end(FAVICON) 55 | else 56 | next() 57 | 58 | 59 | module.exports = (grunt) -> 60 | grunt.config('connect', 61 | options: 62 | onCreateServer: (server, connect, options) -> 63 | connect.watchers = _.reduce(['src', 'test'], (watchers, type) -> 64 | file = if type == 'src' then './src/index.coffee' else './test/quill.coffee' 65 | b = browserify(file, browserifyOps) 66 | watchers[type] = watchify(b) 67 | watchers[type].transform(coffeeify) 68 | watchers[type].transform(stylify) 69 | watchers[type].on('update', _.bind(bundle, watchers[type], watchers[type])) 70 | bundle(watchers[type]) 71 | return watchers 72 | , {}) 73 | connect.karmaProxy = proxy.createProxyServer({ target: "http://localhost:#{grunt.config('karmaPort')}" }) 74 | middleware: (connect, options, middlewares) -> 75 | middlewares.push(serve.bind(this, connect)) 76 | middlewares.push(harp.mount(__dirname + '/../..')) 77 | return middlewares 78 | debug: true 79 | server: 80 | options: 81 | port: grunt.config('port') 82 | useAvailablePort: true 83 | ) 84 | 85 | grunt.event.once('connect.server.listening', (host, port) -> 86 | grunt.config('port', port) 87 | ) 88 | -------------------------------------------------------------------------------- /examples/advanced.jade: -------------------------------------------------------------------------------- 1 | - var colors = ["rgb(0, 0, 0)", "rgb(230, 0, 0)", "rgb(255, 153, 0)", "rgb(255, 255, 0)", "rgb(0, 138, 0)", "rgb(0, 102, 204)", "rgb(153, 51, 255)", "rgb(255, 255, 255)", "rgb(250, 204, 204)", "rgb(255, 235, 204)", "rgb(255, 255, 204)", "rgb(204, 232, 204)", "rgb(204, 224, 245)", "rgb(235, 214, 255)", "rgb(187, 187, 187)", "rgb(240, 102, 102)", "rgb(255, 194, 102)", "rgb(255, 255, 102)", "rgb(102, 185, 102)", "rgb(102, 163, 224)", "rgb(194, 133, 255)", "rgb(136, 136, 136)", "rgb(161, 0, 0)", "rgb(178, 107, 0)", "rgb(178, 178, 0)", "rgb(0, 97, 0)", "rgb(0, 71, 178)", "rgb(107, 36, 178)", "rgb(68, 68, 68)", "rgb(92, 0, 0)", "rgb(102, 61, 0)", "rgb(102, 102, 0)", "rgb(0, 55, 0)", "rgb(0, 41, 102)", "rgb(61, 20, 102)"] 2 | 3 | doctype html 4 | html 5 | head 6 | title Quill Pretty Editor Demo 7 | link(rel='stylesheet', type='text/css', href='../quill.snow.css') 8 | link(rel='stylesheet', type='text/css', href='styles/advanced.css') 9 | meta(charset='utf-8') 10 | 11 | body 12 | #content-container 13 | .basic-wrapper 14 | .toolbar-container 15 | select.ql-font(title='Font') 16 | option(value='sans-serif', selected) Sans Serif 17 | option(value='Georgia, serif') Serif 18 | option(value='Monaco, \'Courier New\', monospace') Monospace 19 | select.ql-size(title='Size') 20 | option(value='10px') Small 21 | option(value='13px', selected) Normal 22 | option(value='18px') Large 23 | option(value='32px') Huge 24 | select.ql-align(title='Text Alignment') 25 | option(value='left', selected) Left 26 | option(value='center') Center 27 | option(value='right') Right 28 | option(value='justify') Justify 29 | button.ql-bold(title='Bold') Bold 30 | button.ql-italic(title='Italic') Italic 31 | button.ql-underline(title='Underline') Under 32 | button.ql-list(title='List') List 33 | .editor-container 34 | 35 | .advanced-wrapper 36 | .toolbar-container 37 | span.ql-format-group 38 | select.ql-font(title='Font') 39 | option(value='sans-serif', selected) Sans Serif 40 | option(value='Georgia, serif') Serif 41 | option(value='Monaco, \'Courier New\', monospace') Monospace 42 | select.ql-size(title='Size') 43 | option(value='10px') Small 44 | option(value='13px', selected) Normal 45 | option(value='18px') Large 46 | option(value='32px') Huge 47 | span.ql-format-group 48 | span.ql-format-button.ql-bold(title='Bold') 49 | span.ql-format-separator 50 | span.ql-format-button.ql-italic(title='Italic') 51 | span.ql-format-separator 52 | span.ql-format-button.ql-underline(title='Underline') 53 | span.ql-format-group 54 | each c in ['ql-color', 'ql-background'] 55 | select(class=c, title=(c=='ql-color'?'Text Color':'Background Color')) 56 | each color,i in colors 57 | option(value=color, selected=((c == 'ql-color' && color == 'rgb(0, 0, 0)') || (c == 'ql-background' && color == 'rgb(255, 255, 255)') ? true : false)) 58 | if c == 'ql-color' 59 | span.ql-format-separator 60 | select.ql-align(title='Text Alignment') 61 | option(value='left', selected) 62 | option(value='center') 63 | option(value='right') 64 | option(value='justify') 65 | span.ql-format-group 66 | span.ql-format-button.ql-link(title='Link') 67 | span.ql-format-separator 68 | span.ql-format-button.ql-image(title='Image') 69 | span.ql-format-separator 70 | span.ql-format-button.ql-list(title='List') 71 | .editor-container 72 | 73 | script(type='text/javascript', src='http://ajax.googleapis.com/ajax/libs/jquery/1.9.0/jquery.js') 74 | script(type='text/javascript', src='http://cdnjs.cloudflare.com/ajax/libs/lodash.js/2.4.1/lodash.js') 75 | script(type='text/javascript', src='../quill.js') 76 | script(type='text/javascript', src='scripts/advanced.js') 77 | -------------------------------------------------------------------------------- /src/modules/video-tooltip.coffee: -------------------------------------------------------------------------------- 1 | Quill = require('../quill') 2 | Tooltip = require('./tooltip') 3 | _ = Quill.require('lodash') 4 | dom = Quill.require('dom') 5 | Delta = Quill.require('delta') 6 | Range = Quill.require('range') 7 | 8 | 9 | class VideoTooltip extends Tooltip 10 | @DEFAULTS: 11 | template: 12 | ' 13 |
14 | Preview 15 |
16 | Cancel 17 | Insert' 18 | 19 | constructor: (@quill, @options) -> 20 | @options = _.defaults(@options, Tooltip.DEFAULTS) 21 | super(@quill, @options) 22 | @embedURL = '' 23 | @preview = @container.querySelector('.preview') 24 | @textbox = @container.querySelector('.input') 25 | dom(@container).addClass('ql-video-tooltip') 26 | config = 27 | tag: 'IFRAME' 28 | attribute: 'src' 29 | 30 | @quill.addFormat('video', config) 31 | this.initListeners() 32 | 33 | initListeners: -> 34 | dom(@container.querySelector('.insert')).on('click', _.bind(this.insertVideo, this)) 35 | dom(@container.querySelector('.cancel')).on('click', _.bind(this.hide, this)) 36 | dom(@textbox).on('input', _.bind(this._preview, this)) 37 | this.initTextbox(@textbox, this.insertVideo, this.hide) 38 | @quill.onModuleLoad('toolbar', (toolbar) => 39 | toolbar.initFormat('video', _.bind(this._onToolbar, this)) 40 | ) 41 | 42 | insertVideo: -> 43 | this._normalizeURL(@textbox.value) 44 | @range = new Range(0, 0) unless @range? # If we lost the selection somehow, just put image at beginning of document 45 | if @range 46 | @preview.innerHTML = 'Preview' 47 | @textbox.value = '' 48 | index = @range.end 49 | @quill.insertEmbed(index, 'video', @embedURL, 'user') 50 | @quill.setSelection(index + 1, index + 1) 51 | this.hide() 52 | 53 | _onToolbar: (range, value) -> 54 | if value 55 | @textbox.value = 'http://' unless @textbox.value 56 | this.show() 57 | @textbox.focus() 58 | _.defer( => 59 | @textbox.setSelectionRange(@textbox.value.length, @textbox.value.length) 60 | ) 61 | else 62 | @quill.deleteText(range, 'user') 63 | 64 | _preview: -> 65 | this._normalizeURL(@textbox.value) 66 | # return unless this._matchVideoURL(@textbox.value) 67 | if @preview.firstChild.tagName == 'IFRAME' 68 | @preview.firstChild.setAttribute('src', @embedURL) 69 | else 70 | img = document.createElement('iframe') 71 | img.setAttribute('src', @embedURL) 72 | @preview.replaceChild(img, @preview.firstChild) 73 | 74 | _matchVideoURL: (url) -> 75 | return true 76 | # return /^https?:\/\/.+\.(jpe?g|gif|png)$/.test(url) 77 | 78 | _normalizeURL: (url) -> 79 | url = new URL(url) 80 | if /youtube.com$/.test(url.hostname) 81 | @provider = 'youtube' 82 | this._normalizeYoutubeURL(url) 83 | else if /vimeo.com$/.test(url.hostname) 84 | @provider = 'vimeo' 85 | this._normalizeVimeoURL(url) 86 | else if /dailymotion.com$/.test(url.hostname) 87 | @provider = 'dailymotion' 88 | this._normalizeDailymotionURL(url) 89 | 90 | _normalizeVimeoURL: (url) -> 91 | if url.protocol == "https:" 92 | vimeoID = url.toString().substring(18) 93 | else 94 | vimeoID = url.toString().substring(17) 95 | @embedURL = "#{url.protocol}//player.vimeo.com/video/#{vimeoID}" 96 | 97 | _normalizeYoutubeURL: (url) -> 98 | if url.toString().length > 28 99 | queryString = {} 100 | url.toString().replace(new RegExp("([^?=&]+)(=([^&]*))?", "g"), ($0, $1, $2, $3) -> queryString[$1] = $3) 101 | youtubeID = queryString['v'] 102 | else 103 | youtubeID = youtubeURL.substring(16) 104 | @embedURL = "http://www.youtube.com/embed/#{youtubeID}" 105 | 106 | _normalizeDailymotionURL: (url) -> 107 | dailymotionID = if (m = url.toString().match(new RegExp("\/video\/([^_?#]+).*?"))) then m[1] else "void 0" 108 | @embedURL = "http://www.dailymotion.com/embed/video/#{dailymotionID}" 109 | 110 | Quill.registerModule('video-tooltip', VideoTooltip) 111 | module.exports = VideoTooltip 112 | -------------------------------------------------------------------------------- /test/unit/modules/keyboard.coffee: -------------------------------------------------------------------------------- 1 | dom = Quill.Lib.DOM 2 | 3 | describe('Keyboard', -> 4 | beforeEach( -> 5 | @container = $('#editor-container').get(0) 6 | ) 7 | 8 | describe('toggleFormat()', -> 9 | beforeEach( -> 10 | @container.innerHTML = ' 11 |
12 |
012345
13 |
' 14 | @quill = new Quill(@container.firstChild) 15 | @keyboard = @quill.getModule('keyboard') 16 | ) 17 | 18 | it('set if all unset', -> 19 | @keyboard.toggleFormat(new Quill.Lib.Range(3, 6), 'strike') 20 | expect(@quill.root.firstChild).toEqualHTML('012345') 21 | ) 22 | 23 | it('unset if all set', -> 24 | @keyboard.toggleFormat(new Quill.Lib.Range(2, 4), 'bold') 25 | expect(@quill.root.firstChild).toEqualHTML('012345') 26 | ) 27 | 28 | it('set if partially set', -> 29 | @keyboard.toggleFormat(new Quill.Lib.Range(3, 5), 'bold') 30 | expect(@quill.root.firstChild).toEqualHTML('012345') 31 | ) 32 | ) 33 | 34 | describe('hotkeys', -> 35 | beforeEach( -> 36 | @container.innerHTML = '
0123
' 37 | @quill = new Quill(@container.firstChild) 38 | @keyboard = @quill.getModule('keyboard') 39 | ) 40 | 41 | it('trigger', (done) -> 42 | hotkey = { key: 'B', metaKey: true } 43 | @keyboard.addHotkey(hotkey, (range) -> 44 | expect(range.start).toEqual(1) 45 | expect(range.end).toEqual(2) 46 | done() 47 | ) 48 | @quill.setSelection(1, 2) 49 | dom(@quill.root).trigger('keydown', hotkey) 50 | ) 51 | 52 | it('format', -> 53 | @quill.setSelection(0, 4) 54 | dom(@quill.root).trigger('keydown', Quill.Module.Keyboard.hotkeys.BOLD) 55 | expect(@quill.root).toEqualHTML('
0123
', true) 56 | ) 57 | 58 | it('tab', -> 59 | @quill.setSelection(1, 3) 60 | dom(@quill.root).trigger('keydown', Quill.Module.Keyboard.hotkeys.INDENT) 61 | expect(@quill.root).toEqualHTML('
0\t3
', true) 62 | ) 63 | 64 | it('shift + tab', -> 65 | @quill.setSelection(0, 2) 66 | dom(@quill.root).trigger('keydown', Quill.Module.Keyboard.hotkeys.OUTDENT) 67 | expect(@quill.root).toEqualHTML('
0123
', true) 68 | ) 69 | 70 | it('retain formatting', -> 71 | @quill.addModule('toolbar', { container: $('#toolbar-container').get(0) }) 72 | size = '18px' 73 | 74 | @quill.setText('foo bar baz') 75 | @quill.formatText(0, @quill.getLength(), { 'bold': true, 'size': size }) 76 | 77 | @quill.setSelection(@quill.getLength(), @quill.getLength()) 78 | dom(@quill.root).trigger('keydown', { key: dom.KEYS.ENTER }) 79 | 80 | expect(dom($('.ql-bold').get(0)).hasClass('ql-active')).toBe(true) 81 | expect(dom($('.ql-size').get(0)).value()).toBe(size) 82 | ) 83 | 84 | it('removeHotkeys by name', -> 85 | counter = 0 86 | fn = -> counter += 1 87 | keyboard = @quill.getModule('keyboard') 88 | keyboard.addHotkey('S', fn) 89 | dom(@quill.root).trigger('keydown', { key: 'S' }) 90 | expect(counter).toBe(1) 91 | result = keyboard.removeHotkeys('S', fn) 92 | expect(result.length).toBe(1) 93 | expect(result[0]).toBe(fn); 94 | dom(@quill.root).trigger('keydown', { key: 'S' }) 95 | expect(counter).toBe(1) 96 | ) 97 | 98 | it('removeHotkeys by object', -> 99 | counter = 0 100 | fn = -> counter += 1 101 | keyboard = @quill.getModule('keyboard') 102 | keyboard.addHotkey({ key: 'S', metaKey: true }, fn) 103 | dom(@quill.root).trigger('keydown', { key: 'S', metaKey: true }) 104 | result = keyboard.removeHotkeys({ key: 'S', metaKey: true }) 105 | expect(result.length).toBe(1) 106 | expect(result[0]).toBe(fn) 107 | dom(@quill.root).trigger('keydown', { key: 'S', metaKey: true }) 108 | expect(counter).toBe(1) 109 | ) 110 | 111 | it('removeHotKeys only the specified callback', -> 112 | fn = -> 113 | anotherFn = -> 114 | keyboard = @quill.getModule('keyboard') 115 | keyboard.addHotkey({ key: 'S', metaKey: true }, fn) 116 | keyboard.addHotkey({ key: 'S', metaKey: true }, anotherFn) 117 | result = keyboard.removeHotkeys({ key: 'S', metaKey: true }, fn) 118 | expect(result.length).toBe(1) 119 | expect(result[0]).toBe(fn) 120 | ) 121 | ) 122 | ) 123 | -------------------------------------------------------------------------------- /src/modules/multi-cursor.coffee: -------------------------------------------------------------------------------- 1 | Quill = require('../quill') 2 | EventEmitter2 = require('eventemitter2').EventEmitter2 3 | _ = Quill.require('lodash') 4 | dom = Quill.require('dom') 5 | 6 | 7 | class MultiCursor extends EventEmitter2 8 | @DEFAULTS: 9 | template: 10 | ' 11 | 12 | 13 | ' 14 | timeout: 2500 15 | 16 | @events: 17 | CURSOR_ADDED: 'cursor-addded' 18 | CURSOR_MOVED: 'cursor-moved' 19 | CURSOR_REMOVED: 'cursor-removed' 20 | 21 | constructor: (@quill, @options) -> 22 | @cursors = {} 23 | @container = @quill.addContainer('ql-multi-cursor', true) 24 | @quill.on(@quill.constructor.events.TEXT_CHANGE, _.bind(this._applyDelta, this)) 25 | 26 | clearCursors: -> 27 | _.each(Object.keys(@cursors), _.bind(this.removeCursor, this)) 28 | @cursors = {} 29 | 30 | moveCursor: (userId, index) -> 31 | cursor = @cursors[userId] 32 | return unless cursor? 33 | cursor.index = index 34 | dom(cursor.elem).removeClass('hidden') 35 | clearTimeout(cursor.timer) 36 | cursor.timer = setTimeout( => 37 | dom(cursor.elem).addClass('hidden') 38 | cursor.timer = null 39 | , @options.timeout) 40 | this._updateCursor(cursor) 41 | return cursor 42 | 43 | removeCursor: (userId) -> 44 | cursor = @cursors[userId] 45 | this.emit(MultiCursor.events.CURSOR_REMOVED, cursor) 46 | cursor.elem.parentNode.removeChild(cursor.elem) if cursor? 47 | delete @cursors[userId] 48 | 49 | setCursor: (userId, index, name, color) -> 50 | unless @cursors[userId]? 51 | @cursors[userId] = cursor = { 52 | userId: userId 53 | index: index 54 | color: color 55 | elem: this._buildCursor(name, color) 56 | } 57 | this.emit(MultiCursor.events.CURSOR_ADDED, cursor) 58 | _.defer( => 59 | this.moveCursor(userId, index) 60 | ) 61 | return @cursors[userId] 62 | 63 | shiftCursors: (index, length, authorId = null) -> 64 | _.each(@cursors, (cursor, id) => 65 | return unless cursor 66 | shift = Math.max(length, index - cursor.index) 67 | if cursor.userId == authorId 68 | this.moveCursor(authorId, cursor.index + shift) 69 | else if cursor.index > index 70 | cursor.index += shift 71 | ) 72 | 73 | update: -> 74 | _.each(@cursors, (cursor, id) => 75 | return unless cursor? 76 | this._updateCursor(cursor) 77 | return true 78 | ) 79 | 80 | _applyDelta: (delta) -> 81 | index = 0 82 | _.each(delta.ops, (op) => 83 | length = 0 84 | if op.insert? 85 | length = op.insert.length or 1 86 | this.shiftCursors(index, length, op.attributes?['author']) 87 | else if op.delete? 88 | this.shiftCursors(index, -1*op.delete, null) 89 | else if op.retain? 90 | this.shiftCursors(index, 0, null) 91 | length = op.retain 92 | index += length 93 | ) 94 | this.update() 95 | 96 | _buildCursor: (name, color) -> 97 | cursor = document.createElement('span') 98 | dom(cursor).addClass('cursor') 99 | cursor.innerHTML = @options.template 100 | cursorFlag = cursor.querySelector('.cursor-flag') 101 | cursorName = cursor.querySelector('.cursor-name') 102 | dom(cursorName).text(name) 103 | cursorCaret = cursor.querySelector('.cursor-caret') 104 | cursorCaret.style.backgroundColor = cursorName.style.backgroundColor = color 105 | @container.appendChild(cursor) 106 | return cursor 107 | 108 | _updateCursor: (cursor) -> 109 | bounds = @quill.getBounds(cursor.index) 110 | return this.removeCursor(cursor.userId) unless bounds? 111 | cursor.elem.style.top = (bounds.top + @quill.container.scrollTop) + 'px' 112 | cursor.elem.style.left = bounds.left + 'px' 113 | cursor.elem.style.height = bounds.height + 'px' 114 | flag = cursor.elem.querySelector('.cursor-flag') 115 | dom(cursor.elem).toggleClass('top', parseInt(cursor.elem.style.top) <= flag.offsetHeight) 116 | .toggleClass('left', parseInt(cursor.elem.style.left) <= flag.offsetWidth) 117 | .toggleClass('right', @quill.root.offsetWidth - parseInt(cursor.elem.style.left) <= flag.offsetWidth) 118 | this.emit(MultiCursor.events.CURSOR_MOVED, cursor) 119 | 120 | 121 | Quill.registerModule('multi-cursor', MultiCursor) 122 | module.exports = MultiCursor 123 | -------------------------------------------------------------------------------- /src/modules/link-tooltip.coffee: -------------------------------------------------------------------------------- 1 | Quill = require('../quill') 2 | Tooltip = require('./tooltip') 3 | _ = Quill.require('lodash') 4 | dom = Quill.require('dom') 5 | 6 | class LinkTooltip extends Tooltip 7 | @DEFAULTS: 8 | maxLength: 50 9 | template: 10 | 'Visit URL:  11 | 12 | 13 |  -  14 | Change 15 | Remove 16 | Done' 17 | 18 | @hotkeys: 19 | LINK: { key: 'K', metaKey: true } 20 | 21 | constructor: (@quill, @options) -> 22 | @options = _.defaults(@options, Tooltip.DEFAULTS) 23 | super(@quill, @options) 24 | dom(@container).addClass('ql-link-tooltip') 25 | @textbox = @container.querySelector('.input') 26 | @link = @container.querySelector('.url') 27 | this.initListeners() 28 | 29 | initListeners: -> 30 | @quill.on(@quill.constructor.events.SELECTION_CHANGE, (range) => 31 | return unless range? and range.isCollapsed() 32 | anchor = this._findAnchor(range) 33 | if anchor 34 | this.setMode(anchor.href, false) 35 | this.show(anchor) 36 | else if @container.style.left != Tooltip.HIDE_MARGIN 37 | @range = null # Prevent restoring selection to last saved 38 | this.hide() 39 | ) 40 | dom(@container.querySelector('.done')).on('click', _.bind(this.saveLink, this)) 41 | dom(@container.querySelector('.remove')).on('click', => 42 | this.removeLink(@range) 43 | ) 44 | dom(@container.querySelector('.change')).on('click', => 45 | this.setMode(@link.href, true) 46 | ) 47 | this.initTextbox(@textbox, this.saveLink, this.hide) 48 | @quill.onModuleLoad('toolbar', (toolbar) => 49 | @toolbar = toolbar 50 | toolbar.initFormat('link', _.bind(this._onToolbar, this)) 51 | ) 52 | @quill.onModuleLoad('keyboard', (keyboard) => 53 | keyboard.addHotkey(LinkTooltip.hotkeys.LINK, _.bind(this._onKeyboard, this)) 54 | ) 55 | 56 | saveLink: -> 57 | url = this._normalizeURL(@textbox.value) 58 | if @range? 59 | end = @range.end 60 | if @range.isCollapsed() 61 | anchor = this._findAnchor(@range) 62 | anchor.href = url if anchor? 63 | else 64 | @quill.formatText(@range, 'link', url, 'user') 65 | @quill.setSelection(end, end) 66 | this.setMode(url, false) 67 | 68 | removeLink: (range) -> 69 | # Expand range to the entire leaf 70 | if range.isCollapsed() 71 | range = this._expandRange(range) 72 | this.hide() 73 | @quill.formatText(range, 'link', false, 'user') 74 | @toolbar.setActive('link', false) if @toolbar? 75 | 76 | setMode: (url, edit = false) -> 77 | if edit 78 | @textbox.value = url 79 | _.defer( => 80 | # Setting value and immediately focusing doesn't work on Chrome 81 | @textbox.focus() 82 | @textbox.setSelectionRange(0, url.length) 83 | ) 84 | else 85 | @link.href = url 86 | url = @link.href # read back the url for further normalization 87 | text = if url.length > @options.maxLength then url.slice(0, @options.maxLength) + '...' else url 88 | dom(@link).text(text) 89 | dom(@container).toggleClass('editing', edit) 90 | 91 | _findAnchor: (range) -> 92 | [leaf, offset] = @quill.editor.doc.findLeafAt(range.start, true) 93 | node = leaf.node if leaf? 94 | while node? and node != @quill.root 95 | return node if node.tagName == 'A' 96 | node = node.parentNode 97 | return null 98 | 99 | _expandRange: (range) -> 100 | [leaf, offset] = @quill.editor.doc.findLeafAt(range.start, true) 101 | start = range.start - offset 102 | end = start + leaf.length 103 | return { start, end } 104 | 105 | _onToolbar: (range, value) -> 106 | this._toggle(range, value) 107 | 108 | _onKeyboard: -> 109 | range = @quill.getSelection() 110 | this._toggle(range, !this._findAnchor(range)) 111 | 112 | _toggle: (range, value) -> 113 | return unless range 114 | if !value 115 | this.removeLink(range) 116 | else if !range.isCollapsed() 117 | this.setMode(this._suggestURL(range), true) 118 | nativeRange = @quill.editor.selection._getNativeRange() 119 | this.show(nativeRange) 120 | 121 | _normalizeURL: (url) -> 122 | url = 'http://' + url unless /^(https?:\/\/|mailto:)/.test(url) 123 | return url 124 | 125 | _suggestURL: (range) -> 126 | text = @quill.getText(range) 127 | return this._normalizeURL(text) 128 | 129 | 130 | Quill.registerModule('link-tooltip', LinkTooltip) 131 | module.exports = LinkTooltip 132 | -------------------------------------------------------------------------------- /test/unit/modules/undo-manager.coffee: -------------------------------------------------------------------------------- 1 | dom = Quill.Lib.DOM 2 | 3 | describe('UndoManager', -> 4 | beforeEach( -> 5 | @container = jasmine.resetEditor() 6 | @container.innerHTML = ' 7 |
8 |
The lazy fox
9 |
' 10 | @quill = new Quill(@container.firstChild, { 11 | modules: { 12 | 'undo-manager': { delay: 400 } 13 | } 14 | }) 15 | @undoManager = @quill.getModule('undo-manager') 16 | @original = @quill.getContents() 17 | ) 18 | 19 | tests = 20 | 'insert': 21 | delta: new Quill.Delta().retain(9).insert('hairy ') 22 | index: 15 23 | 'delete': 24 | delta: new Quill.Delta().retain(4).delete(5) 25 | index: 4 26 | 'format': 27 | delta: new Quill.Delta().retain(4).retain(5, { bold: true }) 28 | index: 9 29 | 'multiple': 30 | delta: new Quill.Delta().retain(4, { bold: true }).insert('hairy').delete(4) 31 | index: 9 32 | 33 | describe('_getLastChangeIndex', -> 34 | _.each(tests, (test, name) -> 35 | it(name, -> 36 | index = @undoManager._getLastChangeIndex(test.delta) 37 | expect(index).toEqual(test.index) 38 | ) 39 | ) 40 | ) 41 | 42 | describe('undo/redo', -> 43 | _.each(tests, (test, name) -> 44 | it(name, -> 45 | @quill.updateContents(test.delta) 46 | changed = @quill.getContents() 47 | expect(changed).not.toEqualDelta(@original) 48 | @undoManager.undo() 49 | expect(@quill.getContents()).toEqualDelta(@original) 50 | @undoManager.redo() 51 | expect(@quill.getContents()).toEqualDelta(changed) 52 | ) 53 | ) 54 | 55 | it('user change', -> 56 | @quill.root.firstChild.innerHTML = 'The lazy foxes' 57 | @quill.editor.checkUpdate() 58 | changed = @quill.getContents() 59 | expect(changed).not.toEqualDelta(@original) 60 | @undoManager.undo() 61 | expect(@quill.getContents()).toEqualDelta(@original) 62 | @undoManager.redo() 63 | expect(@quill.getContents()).toEqualDelta(changed) 64 | ) 65 | 66 | it('merge changes', -> 67 | expect(@undoManager.stack.undo.length).toEqual(0) 68 | @quill.updateContents(new Quill.Delta().retain(12).insert('e')) 69 | expect(@undoManager.stack.undo.length).toEqual(1) 70 | @quill.updateContents(new Quill.Delta().retain(13).insert('s')) 71 | expect(@undoManager.stack.undo.length).toEqual(1) 72 | @undoManager.undo() 73 | expect(@quill.getContents()).toEqual(@original) 74 | expect(@undoManager.stack.undo.length).toEqual(0) 75 | ) 76 | 77 | it('dont merge changes', (done) -> 78 | expect(@undoManager.stack.undo.length).toEqual(0) 79 | @quill.updateContents(new Quill.Delta().retain(12).insert('e')) 80 | expect(@undoManager.stack.undo.length).toEqual(1) 81 | setTimeout( => 82 | @quill.updateContents(new Quill.Delta().retain(13).insert('s')) 83 | expect(@undoManager.stack.undo.length).toEqual(2) 84 | done() 85 | , @undoManager.options.delay * 1.25) 86 | ) 87 | 88 | it('multiple undos', (done) -> 89 | expect(@undoManager.stack.undo.length).toEqual(0) 90 | @quill.updateContents(new Quill.Delta().retain(12).insert('e')) 91 | contents = @quill.getContents() 92 | setTimeout( => 93 | @quill.updateContents(new Quill.Delta().retain(13).insert('s')) 94 | @undoManager.undo() 95 | expect(@quill.getContents()).toEqual(contents) 96 | @undoManager.undo() 97 | expect(@quill.getContents()).toEqual(@original) 98 | done() 99 | , @undoManager.options.delay * 1.25) 100 | ) 101 | 102 | it('hotkeys', -> 103 | @quill.updateContents(new Quill.Delta().insert('A')) 104 | changed = @quill.getContents() 105 | expect(changed).not.toEqualDelta(@original) 106 | dom(@quill.root).trigger('keydown', Quill.Module.UndoManager.hotkeys.UNDO) 107 | expect(@quill.getContents()).toEqualDelta(@original) 108 | dom(@quill.root).trigger('keydown', Quill.Module.UndoManager.hotkeys.REDO) 109 | expect(@quill.getContents()).toEqualDelta(changed) 110 | ) 111 | 112 | it('api change transform', -> 113 | @quill.getModule('undo-manager').options.userOnly = true 114 | @quill.updateContents(new Quill.Delta().retain(12).insert('es'), Quill.sources.USER) 115 | @quill.updateContents(new Quill.Delta().retain(4).delete(5), Quill.sources.API) 116 | @quill.updateContents(new Quill.Delta().retain(9).insert('!'), Quill.sources.USER) 117 | expect(@undoManager.stack.undo.length).toEqual(1) 118 | expect(@quill.getContents()).toEqual(new Quill.Delta().insert('The foxes!\n')) 119 | @undoManager.undo() 120 | expect(@quill.getContents()).toEqual(new Quill.Delta().insert('The fox\n')) 121 | @undoManager.redo() 122 | expect(@quill.getContents()).toEqual(new Quill.Delta().insert('The foxes!\n')) 123 | ) 124 | ) 125 | ) 126 | -------------------------------------------------------------------------------- /dist/quill.base.css: -------------------------------------------------------------------------------- 1 | /*! Quill Editor v0.20.1 2 | * https://quilljs.com/ 3 | * Copyright (c) 2014, Jason Chen 4 | * Copyright (c) 2013, salesforce.com 5 | */ 6 | .ql-image-tooltip { 7 | padding: 10px; 8 | width: 300px; 9 | } 10 | .ql-image-tooltip:after { 11 | clear: both; 12 | content: ""; 13 | display: table; 14 | } 15 | .ql-image-tooltip a { 16 | border: 1px solid #000; 17 | box-sizing: border-box; 18 | display: inline-block; 19 | float: left; 20 | padding: 5px; 21 | text-align: center; 22 | width: 50%; 23 | } 24 | .ql-image-tooltip img { 25 | bottom: 0; 26 | left: 0; 27 | margin: auto; 28 | max-height: 100%; 29 | max-width: 100%; 30 | position: absolute; 31 | right: 0; 32 | top: 0; 33 | } 34 | .ql-image-tooltip .input { 35 | box-sizing: border-box; 36 | width: 100%; 37 | } 38 | .ql-image-tooltip .preview { 39 | margin: 10px 0px; 40 | position: relative; 41 | border: 1px dashed #000; 42 | height: 200px; 43 | } 44 | .ql-image-tooltip .preview span { 45 | display: inline-block; 46 | position: absolute; 47 | text-align: center; 48 | top: 40%; 49 | width: 100%; 50 | } 51 | .ql-link-tooltip { 52 | padding: 5px 10px; 53 | } 54 | .ql-link-tooltip input.input { 55 | width: 170px; 56 | } 57 | .ql-link-tooltip input.input, 58 | .ql-link-tooltip a.done { 59 | display: none; 60 | } 61 | .ql-link-tooltip a.change { 62 | margin-right: 4px; 63 | } 64 | .ql-link-tooltip.editing input.input, 65 | .ql-link-tooltip.editing a.done { 66 | display: inline-block; 67 | } 68 | .ql-link-tooltip.editing a.url, 69 | .ql-link-tooltip.editing a.change, 70 | .ql-link-tooltip.editing a.remove { 71 | display: none; 72 | } 73 | .ql-multi-cursor { 74 | position: absolute; 75 | left: 0; 76 | top: 0; 77 | z-index: 1000; 78 | } 79 | .ql-multi-cursor .cursor { 80 | margin-left: -1px; 81 | position: absolute; 82 | } 83 | .ql-multi-cursor .cursor-flag { 84 | bottom: 100%; 85 | position: absolute; 86 | white-space: nowrap; 87 | } 88 | .ql-multi-cursor .cursor-name { 89 | display: inline-block; 90 | color: #fff; 91 | padding: 2px 8px; 92 | } 93 | .ql-multi-cursor .cursor-caret { 94 | height: 100%; 95 | position: absolute; 96 | width: 2px; 97 | } 98 | .ql-multi-cursor .cursor.hidden .cursor-flag { 99 | display: none; 100 | } 101 | .ql-multi-cursor .cursor.top .cursor-flag { 102 | bottom: auto; 103 | top: 100%; 104 | } 105 | .ql-multi-cursor .cursor.right .cursor-flag { 106 | right: -2px; 107 | } 108 | .ql-paste-manager { 109 | left: -100000px; 110 | position: absolute; 111 | top: 50%; 112 | } 113 | .ql-toolbar { 114 | box-sizing: border-box; 115 | } 116 | .ql-tooltip { 117 | background-color: #fff; 118 | border: 1px solid #000; 119 | box-sizing: border-box; 120 | position: absolute; 121 | top: 0px; 122 | white-space: nowrap; 123 | z-index: 2000; 124 | } 125 | .ql-tooltip a { 126 | cursor: pointer; 127 | text-decoration: none; 128 | } 129 | .ql-video-tooltip { 130 | padding: 10px; 131 | width: 300px; 132 | } 133 | .ql-video-tooltip:after { 134 | clear: both; 135 | content: ""; 136 | display: table; 137 | } 138 | .ql-video-tooltip a { 139 | border: 1px solid #000; 140 | box-sizing: border-box; 141 | display: inline-block; 142 | float: left; 143 | padding: 5px; 144 | text-align: center; 145 | width: 50%; 146 | } 147 | .ql-video-tooltip img { 148 | bottom: 0; 149 | left: 0; 150 | margin: auto; 151 | max-height: 100%; 152 | max-width: 100%; 153 | position: absolute; 154 | right: 0; 155 | top: 0; 156 | } 157 | .ql-video-tooltip .input { 158 | box-sizing: border-box; 159 | width: 100%; 160 | } 161 | .ql-video-tooltip .preview { 162 | margin: 10px 0px; 163 | position: relative; 164 | border: 1px dashed #000; 165 | height: 200px; 166 | } 167 | .ql-video-tooltip .preview iframe { 168 | height: 196px; 169 | width: 272px; 170 | } 171 | .ql-video-tooltip .preview span { 172 | display: inline-block; 173 | position: absolute; 174 | text-align: center; 175 | top: 40%; 176 | width: 100%; 177 | } 178 | .ql-container { 179 | box-sizing: border-box; 180 | cursor: text; 181 | font-family: Helvetica, 'Arial', sans-serif; 182 | font-size: 13px; 183 | height: 100%; 184 | line-height: 1.42; 185 | margin: 0px; 186 | overflow-x: hidden; 187 | overflow-y: auto; 188 | padding: 12px 15px; 189 | position: relative; 190 | } 191 | .ql-editor { 192 | box-sizing: border-box; 193 | min-height: 100%; 194 | outline: none; 195 | tab-size: 4; 196 | white-space: pre-wrap; 197 | } 198 | .ql-editor div { 199 | margin: 0; 200 | padding: 0; 201 | } 202 | .ql-editor a { 203 | text-decoration: underline; 204 | } 205 | .ql-editor b { 206 | font-weight: bold; 207 | } 208 | .ql-editor i { 209 | font-style: italic; 210 | } 211 | .ql-editor s { 212 | text-decoration: line-through; 213 | } 214 | .ql-editor u { 215 | text-decoration: underline; 216 | } 217 | .ql-editor a, 218 | .ql-editor b, 219 | .ql-editor i, 220 | .ql-editor s, 221 | .ql-editor u, 222 | .ql-editor span { 223 | background-color: inherit; 224 | } 225 | .ql-editor img { 226 | max-width: 100%; 227 | } 228 | .ql-editor blockquote, 229 | .ql-editor ol, 230 | .ql-editor ul { 231 | margin: 0 0 0 2em; 232 | padding: 0; 233 | } 234 | .ql-editor ol { 235 | list-style-type: decimal; 236 | } 237 | .ql-editor ul { 238 | list-style-type: disc; 239 | } 240 | .ql-editor.ql-ie-9 br, 241 | .ql-editor.ql-ie-10 br { 242 | display: none; 243 | } 244 | -------------------------------------------------------------------------------- /src/core/document.coffee: -------------------------------------------------------------------------------- 1 | _ = require('lodash') 2 | Delta = require('rich-text/lib/delta') 3 | dom = require('../lib/dom') 4 | Format = require('./format') 5 | Line = require('./line') 6 | LinkedList = require('../lib/linked-list') 7 | Normalizer = require('./normalizer') 8 | 9 | 10 | class Document 11 | constructor: (@root, options = {}) -> 12 | @normalizer = new Normalizer() 13 | @formats = {} 14 | _.each(options.formats, _.bind(this.addFormat, this)) 15 | this.setHTML(@root.innerHTML) 16 | 17 | addFormat: (name, config) -> 18 | config = Format.FORMATS[name] unless _.isObject(config) 19 | console.warn('Overwriting format', name, @formats[name]) if @formats[name]? 20 | @formats[name] = new Format(config) 21 | @normalizer.addFormat(config) 22 | 23 | appendLine: (lineNode) -> 24 | return this.insertLineBefore(lineNode, null) 25 | 26 | findLeafAt: (index, inclusive) -> 27 | [line, offset] = this.findLineAt(index) 28 | return if line? then line.findLeafAt(offset, inclusive) else [undefined, offset] 29 | 30 | findLine: (node) -> 31 | while node? and !dom.BLOCK_TAGS[node.tagName]? 32 | node = node.parentNode 33 | line = if node? then dom(node).data(Line.DATA_KEY) else undefined 34 | return if line?.node == node then line else undefined 35 | 36 | findLineAt: (index) -> 37 | return [undefined, index] unless @lines.length > 0 38 | length = this.toDelta().length() # TODO optimize 39 | return [@lines.last, @lines.last.length] if index == length 40 | return [undefined, index - length] if index > length 41 | curLine = @lines.first 42 | while curLine? 43 | return [curLine, index] if index < curLine.length 44 | index -= curLine.length 45 | curLine = curLine.next 46 | return [undefined, index] # Should never occur unless length calculation is off 47 | 48 | getHTML: -> 49 | # Preserve spaces between tags 50 | return @root.innerHTML.replace(/\>\s+\ <') 51 | 52 | insertLineBefore: (newLineNode, refLine) -> 53 | line = new Line(this, newLineNode) 54 | if refLine? 55 | @root.insertBefore(newLineNode, refLine.node) unless dom(newLineNode.parentNode).isElement() # Would prefer newLineNode.parentNode? but IE will have non-null object 56 | @lines.insertAfter(refLine.prev, line) 57 | else 58 | @root.appendChild(newLineNode) unless dom(newLineNode.parentNode).isElement() 59 | @lines.append(line) 60 | return line 61 | 62 | mergeLines: (line, lineToMerge) -> 63 | if lineToMerge.length > 1 64 | dom(line.leaves.last.node).remove() if line.length == 1 65 | _.each(dom(lineToMerge.node).childNodes(), (child) -> 66 | line.node.appendChild(child) if child.tagName != dom.DEFAULT_BREAK_TAG 67 | ) 68 | this.removeLine(lineToMerge) 69 | line.rebuild() 70 | 71 | optimizeLines: -> 72 | # TODO optimize algorithm (track which lines get dirty and only Normalize.optimizeLine those) 73 | _.each(@lines.toArray(), (line, i) -> 74 | line.optimize() 75 | return true # line.optimize() might return false, prevent early break 76 | ) 77 | 78 | rebuild: -> 79 | lines = @lines.toArray() 80 | lineNode = @root.firstChild 81 | lineNode = lineNode.firstChild if lineNode? and dom.LIST_TAGS[lineNode.tagName]? 82 | _.each(lines, (line, index) => 83 | while line.node != lineNode 84 | if line.node.parentNode == @root or line.node.parentNode?.parentNode == @root 85 | # New line inserted 86 | lineNode = @normalizer.normalizeLine(lineNode) 87 | newLine = this.insertLineBefore(lineNode, line) 88 | lineNode = dom(lineNode).nextLineNode(@root) 89 | else 90 | # Existing line removed 91 | return this.removeLine(line) 92 | if line.outerHTML != lineNode.outerHTML 93 | # Existing line changed 94 | line.node = @normalizer.normalizeLine(line.node) 95 | line.rebuild() 96 | lineNode = dom(lineNode).nextLineNode(@root) 97 | ) 98 | # New lines appended 99 | while lineNode? 100 | lineNode = @normalizer.normalizeLine(lineNode) 101 | this.appendLine(lineNode) 102 | lineNode = dom(lineNode).nextLineNode(@root) 103 | 104 | removeLine: (line) -> 105 | if line.node.parentNode? 106 | if dom.LIST_TAGS[line.node.parentNode.tagName] and line.node.parentNode.childNodes.length == 1 107 | dom(line.node.parentNode).remove() 108 | else 109 | dom(line.node).remove() 110 | @lines.remove(line) 111 | 112 | setHTML: (html) -> 113 | html = Normalizer.stripComments(html) 114 | html = Normalizer.stripWhitespace(html) 115 | @root.innerHTML = html 116 | @lines = new LinkedList() 117 | this.rebuild() 118 | 119 | splitLine: (line, offset) -> 120 | offset = Math.min(offset, line.length - 1) 121 | [lineNode1, lineNode2] = dom(line.node).split(offset, true) 122 | line.node = lineNode1 123 | line.rebuild() 124 | newLine = this.insertLineBefore(lineNode2, line.next) 125 | newLine.formats = _.clone(line.formats) 126 | newLine.resetContent() 127 | return newLine 128 | 129 | toDelta: -> 130 | lines = @lines.toArray() 131 | delta = new Delta() 132 | _.each(lines, (line) -> 133 | _.each(line.delta.ops, (op) -> 134 | delta.push(op) 135 | ) 136 | ) 137 | return delta 138 | 139 | 140 | module.exports = Document 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Quill Rich Text Editor](http://quilljs.com/) [![Build Status](https://travis-ci.org/quilljs/quill.svg?branch=master)](http://travis-ci.org/quilljs/quill) 2 | 3 | [![Webdriver Test Status](https://saucelabs.com/browser-matrix/quill-master.svg)](https://saucelabs.com/u/quill) 4 | 5 | Quill is a modern rich text editor built for compatibility and extensibility. It was created by [Jason Chen](https://twitter.com/jhchen) and [Byron Milligan](https://twitter.com/byronmilligan) and open sourced by [Salesforce.com](http://www.salesforce.com). 6 | 7 | To get started, check out the [Quill Github Page](http://quilljs.com/) or jump straight into the [demo](http://quilljs.com/examples/). 8 | 9 | ## Quickstart 10 | 11 | Instantiate a new Quill object with a css selector for the div that should become the editor. 12 | 13 | ```html 14 | 15 |
16 | 17 | 18 |
19 | 20 | 21 |
22 |
Hello World!
23 |
24 | 25 | 26 | 27 | 28 | 29 | 33 | ``` 34 | 35 | 36 | ## Downloading Quill 37 | 38 | There are a number of ways to download the latest or versioned copy of Quill. 39 | 40 | - npm: `npm install quill` 41 | - bower: `bower install quill` 42 | - tar: https://github.com/quilljs/quill/releases 43 | 44 | ### CDN 45 | 46 | ```html 47 | 48 | 49 | ``` 50 | 51 | 52 | ## Local Development 53 | 54 | Quill's source is in [Coffeescript](http://coffeescript.org/) and utilizes [Browserify](http://browserify.org/) to organize its files. 55 | 56 | ### Installation 57 | 58 | npm install -g grunt-cli 59 | npm install 60 | 61 | ### Building 62 | 63 | grunt dist - compile and browserify 64 | grunt server - starts a local server that will build and serve assets on the fly 65 | 66 | ### Examples 67 | 68 | With the local server (`grunt server`) running you can try out some minimal examples on: 69 | 70 | - [localhost:9000/examples/index.html](http://localhost:9000/examples/index.html) 71 | - [localhost:9000/examples/advanced.html](http://localhost:9000/examples/advanced.html) 72 | 73 | Quill [releases](https://github.com/quilljs/quill/releases) also contain these examples as built static files you can try without needing to run the local development server. 74 | 75 | ### Testing 76 | 77 | grunt test:unit - runs javascript test suite with Chrome 78 | grunt test:e2e - runs end to end tests with Webdriver + Chrome 79 | grunt test:coverage - run tests measuring coverage with Chrome 80 | 81 | Tests are run by [Karma](http://karma-runner.github.io/) and [Protractor](https://github.com/angular/protractor) using [Jasmine](http://jasmine.github.io/). Check out `Gruntfile.coffee` and `config/grunt/` for more testing options. 82 | 83 | 84 | ## Contributing 85 | 86 | ### Community 87 | 88 | Get help or stay up to date. 89 | 90 | - Follow [@quilljs](https://twitter.com/quilljs) on Twitter 91 | - Ask questions on [Stack Overflow](http://stackoverflow.com/questions/tagged/quill) (tag with quill) 92 | - If a private channel is required, you may also email support@quilljs.com 93 | 94 | ### Bug Reports 95 | 96 | Search through [Github Issues](https://github.com/quilljs/quill/issues) to see if the bug has already been reported. If so, please comment with any additional information about the bug. 97 | 98 | For new issues, create a new issue and tag with the appropriate browser tag. Include as much detail as possible such as: 99 | 100 | - Detailed description of faulty behavior 101 | - Affected platforms 102 | - Steps for reproduction 103 | - Failing test case 104 | 105 | The more details you provide, the more likely we or someone else will be able to find and fix the bug. 106 | 107 | ### Feature Requests 108 | 109 | We welcome feature requests. Please make sure they are within scope of Quill's goals and submit them in [Github Issues](https://github.com/quilljs/quill/issues) tagged with the 'feature' tag. The more complete and compelling the request, the more likely it will be implemented. Garnering community support will help as well! 110 | 111 | ### Pull Requests 112 | 113 | 1. Please check to make sure your plans fall within Quill's scope (likely through Github Issues). 114 | 2. Fork Quill 115 | 3. Branch off of the 'develop' branch. 116 | 4. Implement your changes. 117 | 5. Submit a Pull Request. 118 | 119 | Pull requests will not be accepted without adhering to the following: 120 | 121 | 1. Conform to existing [coding styles](docs/style-guide.md). 122 | 2. New functionality are accompanied by tests. 123 | 3. Serve a single atomic purpose (add one feature or fix one bug) 124 | 4. Introduce only changes that further the PR's singular purpose (ex. do not tweak an unrelated config along with adding your feature). 125 | 126 | **Important:** By issuing a Pull Request you agree to allow the project owners to license your work under the terms of the [License](https://github.com/quilljs/quill/blob/master/LICENSE). 127 | 128 | 129 | ## Thanks 130 | 131 | [Swift](https://github.com/theycallmeswift), for providing the npm package name. If you're looking for his blogging engine see [v0.1.5-1](https://www.npmjs.org/package/quill/0.1.5-1). 132 | 133 | 134 | ## License 135 | 136 | BSD 3-clause 137 | -------------------------------------------------------------------------------- /src/modules/keyboard.coffee: -------------------------------------------------------------------------------- 1 | Quill = require('../quill') 2 | _ = Quill.require('lodash') 3 | dom = Quill.require('dom') 4 | Delta = Quill.require('delta') 5 | 6 | 7 | class Keyboard 8 | @hotkeys: 9 | BOLD: { key: 'B', metaKey: true } 10 | INDENT: { key: dom.KEYS.TAB } 11 | ITALIC: { key: 'I', metaKey: true } 12 | OUTDENT: { key: dom.KEYS.TAB, shiftKey: true } 13 | UNDERLINE: { key: 'U', metaKey: true } 14 | 15 | constructor: (@quill, options) -> 16 | @hotkeys = {} 17 | this._initListeners() 18 | this._initHotkeys() 19 | @quill.onModuleLoad('toolbar', (toolbar) => 20 | @toolbar = toolbar 21 | ) 22 | 23 | addHotkey: (hotkeys, callback) -> 24 | hotkeys = [hotkeys] unless Array.isArray(hotkeys) 25 | _.each(hotkeys, (hotkey) => 26 | hotkey = if _.isObject(hotkey) then _.clone(hotkey) else { key: hotkey } 27 | hotkey.callback = callback 28 | which = if _.isNumber(hotkey.key) then hotkey.key else hotkey.key.toUpperCase().charCodeAt(0) 29 | @hotkeys[which] ?= [] 30 | @hotkeys[which].push(hotkey) 31 | ) 32 | 33 | removeHotkeys: (hotkey, callback) -> 34 | hotkey = if _.isString(hotkey) then hotkey.toUpperCase() else hotkey 35 | hotkey = if Keyboard.hotkeys[hotkey] then Keyboard.hotkeys[hotkey] else hotkey 36 | hotkey = if _.isObject(hotkey) then hotkey else { key: hotkey } 37 | which = if _.isNumber(hotkey.key) then hotkey.key else hotkey.key.charCodeAt(0) 38 | @hotkeys[which] ?= [] 39 | [removed, kept] = _.partition(@hotkeys[which], (handler) -> 40 | _.isEqual(hotkey, _.omit(handler, 'callback')) and 41 | (!callback or callback == handler.callback) 42 | ) 43 | @hotkeys[which] = kept 44 | return _.map(removed, 'callback') 45 | 46 | toggleFormat: (range, format) -> 47 | if range.isCollapsed() 48 | delta = @quill.getContents(Math.max(0, range.start-1), range.end) 49 | else 50 | delta = @quill.getContents(range) 51 | value = delta.ops.length == 0 or !_.all(delta.ops, (op) -> 52 | return op.attributes?[format] 53 | ) 54 | if range.isCollapsed() 55 | @quill.prepareFormat(format, value, Quill.sources.USER) 56 | else 57 | @quill.formatText(range, format, value, Quill.sources.USER) 58 | @toolbar.setActive(format, value) if @toolbar? 59 | 60 | _initEnter: -> 61 | keys = [ 62 | { key: dom.KEYS.ENTER } 63 | { key: dom.KEYS.ENTER, shiftKey: true } 64 | ] 65 | this.addHotkey(keys, (range, hotkey) => 66 | return true unless range? 67 | [line, offset] = @quill.editor.doc.findLineAt(range.start) 68 | [leaf, offset] = line.findLeafAt(offset) 69 | delta = new Delta().retain(range.start).insert('\n', line.formats).delete(range.end - range.start) 70 | @quill.updateContents(delta, Quill.sources.USER) 71 | _.each(leaf.formats, (value, format) => 72 | @quill.prepareFormat(format, value) 73 | @toolbar.setActive(format, value) if @toolbar? 74 | return 75 | ) 76 | @quill.editor.selection.scrollIntoView() 77 | return false 78 | ) 79 | 80 | _initDeletes: -> 81 | this.addHotkey([dom.KEYS.DELETE, dom.KEYS.BACKSPACE], (range, hotkey) => 82 | if range? and @quill.getLength() > 0 83 | if range.start != range.end 84 | @quill.deleteText(range.start, range.end, Quill.sources.USER) 85 | else 86 | if hotkey.key == dom.KEYS.BACKSPACE 87 | [line, offset] = @quill.editor.doc.findLineAt(range.start) 88 | if offset == 0 and (line.formats.bullet or line.formats.list) 89 | format = if line.formats.bullet then 'bullet' else 'list' 90 | @quill.formatLine(range.start, range.start, format, false, Quill.sources.USER) 91 | else if range.start > 0 92 | @quill.deleteText(range.start - 1, range.start, Quill.sources.USER) 93 | else if range.start < @quill.getLength() - 1 94 | @quill.deleteText(range.start, range.start + 1, Quill.sources.USER) 95 | @quill.editor.selection.scrollIntoView() 96 | return false 97 | ) 98 | 99 | _initHotkeys: -> 100 | this.addHotkey(Keyboard.hotkeys.INDENT, (range) => 101 | this._onTab(range, false) 102 | return false 103 | ) 104 | this.addHotkey(Keyboard.hotkeys.OUTDENT, (range) => 105 | # TODO implement when we implement multiline tabs 106 | return false 107 | ) 108 | _.each(['bold', 'italic', 'underline'], (format) => 109 | this.addHotkey(Keyboard.hotkeys[format.toUpperCase()], (range) => 110 | if (@quill.editor.doc.formats[format]) 111 | this.toggleFormat(range, format) 112 | return false 113 | ) 114 | ) 115 | this._initDeletes() 116 | this._initEnter() 117 | 118 | _initListeners: -> 119 | dom(@quill.root).on('keydown', (event) => 120 | prevent = false 121 | _.each(@hotkeys[event.which], (hotkey) => 122 | metaKey = if dom.isMac() then event.metaKey else event.metaKey or event.ctrlKey 123 | return if !!hotkey.metaKey != !!metaKey 124 | return if !!hotkey.shiftKey != !!event.shiftKey 125 | return if !!hotkey.altKey != !!event.altKey 126 | prevent = hotkey.callback(@quill.getSelection(), hotkey, event) == false or prevent 127 | return true 128 | ) 129 | return !prevent 130 | ) 131 | 132 | _onTab: (range, shift = false) -> 133 | # TODO implement multiline tab behavior 134 | # Behavior according to Google Docs + Word 135 | # When tab on one line, regardless if shift is down, delete selection and insert a tab 136 | # When tab on multiple lines, indent each line if possible, outdent if shift is down 137 | delta = new Delta().retain(range.start) 138 | .insert("\t") 139 | .delete(range.end - range.start) 140 | .retain(@quill.getLength() - range.end) 141 | @quill.updateContents(delta, Quill.sources.USER) 142 | @quill.setSelection(range.start + 1, range.start + 1) 143 | 144 | 145 | Quill.registerModule('keyboard', Keyboard) 146 | module.exports = Keyboard 147 | -------------------------------------------------------------------------------- /src/themes/snow/modules/toolbar.styl: -------------------------------------------------------------------------------- 1 | blackColor = #444 2 | blueColor = #06c 3 | grayColor = #ccc 4 | lightGrayColor = #ddd 5 | whiteColor = #fff 6 | 7 | fontNamePickerWidth = 105px 8 | fontSizePickerWidth = 80px 9 | iconPickerWidth = 28px 10 | 11 | inputHeight = 14px 12 | inputPadding = 5px 13 | separatorMargin = 4px 14 | inputSize = inputHeight + 2*inputPadding 15 | toolbarPadding = 8px 16 | 17 | imageSize = 18px 18 | 19 | colorItemMargin = 2px 20 | colorItemWidth = 16px 21 | colorItemsPerRow = 7 22 | 23 | formatGroupMargin = 15px 24 | 25 | formatInput = 26 | box-sizing: border-box 27 | display: inline-block 28 | height: inputSize 29 | line-height: inputSize 30 | vertical-align: middle 31 | 32 | formats = (bold italic underline strike link image video list bullet authorship color background left right center justify) 33 | 34 | 35 | .ql-snow.ql-toolbar 36 | box-sizing: border-box 37 | padding: toolbarPadding 38 | user-select: none 39 | -webkit-user-select: none 40 | -moz-user-select: none 41 | -ms-user-select: none 42 | .ql-format-group 43 | display: inline-block 44 | margin-right: formatGroupMargin 45 | vertical-align: middle 46 | .ql-format-separator 47 | box-sizing: border-box 48 | background-color: lightGrayColor 49 | display: inline-block 50 | height: inputHeight 51 | margin-left: separatorMargin 52 | margin-right: separatorMargin 53 | vertical-align: middle 54 | width: 1px 55 | .ql-format-button 56 | {formatInput} 57 | background-position: center center 58 | background-repeat: no-repeat 59 | background-size: imageSize imageSize 60 | box-sizing: border-box 61 | cursor: pointer 62 | text-align: center 63 | width: inputSize 64 | 65 | .ql-picker 66 | box-sizing: border-box 67 | color: blackColor 68 | display: inline-block 69 | font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif 70 | font-size: 14px 71 | font-weight: 500 72 | position: relative 73 | .ql-picker-label 74 | {formatInput} 75 | background-color: whiteColor 76 | background-position: right center 77 | background-repeat: no-repeat 78 | background-size: imageSize imageSize 79 | border: 1px solid transparent 80 | cursor: pointer 81 | position: relative 82 | width: 100% 83 | .ql-picker-label.ql-active, .ql-picker-label:hover 84 | color: blueColor 85 | .ql-picker-options 86 | background-color: whiteColor 87 | border: 1px solid transparent 88 | box-sizing: border-box 89 | display: none 90 | padding: 0.5*toolbarPadding toolbarPadding 91 | position: absolute 92 | width: 100% 93 | .ql-picker-item 94 | background-position: center center 95 | background-repeat: no-repeat 96 | background-size: imageSize imageSize 97 | box-sizing: border-box 98 | cursor: pointer 99 | display: block 100 | padding-bottom: inputPadding 101 | padding-top: inputPadding 102 | .ql-picker-item.ql-selected, .ql-picker-item:hover 103 | color: blueColor 104 | .ql-picker.ql-expanded 105 | .ql-picker-label 106 | border-color: grayColor 107 | color: grayColor 108 | z-index: 2 109 | .ql-picker-options 110 | border-color: grayColor 111 | box-shadow: rgba(0,0,0,0.2) 0 2px 8px 112 | display: block 113 | margin-top: -1px 114 | z-index: 1 115 | 116 | .ql-picker.ql-color-picker 117 | .ql-picker-label 118 | background-position: center center 119 | width: iconPickerWidth 120 | .ql-picker-options 121 | padding: inputPadding 122 | width: (colorItemWidth + 2*colorItemMargin) * colorItemsPerRow + 2*inputPadding + 2 // +2 for the border 123 | .ql-picker-item 124 | border: 1px solid transparent 125 | float: left 126 | height: colorItemWidth 127 | margin: colorItemMargin 128 | padding: 0px 129 | width: colorItemWidth 130 | .ql-picker-item.ql-primary-color 131 | margin-bottom: toolbarPadding 132 | .ql-picker-item.ql-selected, .ql-picker-item:hover 133 | border-color: #000 134 | 135 | .ql-picker.ql-font 136 | width: fontNamePickerWidth 137 | .ql-picker.ql-size 138 | width: fontSizePickerWidth 139 | .ql-picker.ql-font, .ql-picker.ql-size 140 | .ql-picker-label 141 | padding-left: toolbarPadding 142 | padding-right: toolbarPadding 143 | 144 | .ql-picker.ql-align 145 | .ql-picker-label 146 | background-position: center center 147 | width: iconPickerWidth 148 | .ql-picker-item 149 | {formatInput} 150 | padding: 0px 151 | width: iconPickerWidth 152 | .ql-picker-options 153 | padding: 4px 0px 154 | 155 | 156 | imageRules(retina) 157 | suffix = retina ? '@2x' : '' 158 | .ql-snow.ql-toolbar 159 | .ql-picker .ql-picker-label 160 | background-image: url('assets/dropdown' + suffix + '.png') 161 | .ql-picker.ql-expanded .ql-picker-label 162 | background-image: url('assets/inactive/dropdown' + suffix + '.png') 163 | .ql-snow.ql-toolbar .ql-picker.ql-active:not(.ql-expanded) .ql-picker-label, 164 | .ql-snow.ql-toolbar:not(.ios) .ql-picker:not(.ql-expanded) .ql-picker-label:hover 165 | background-image: url('assets/active/dropdown' + suffix + '.png') 166 | 167 | for format in formats 168 | .ql-snow.ql-toolbar 169 | .ql-format-button.ql-{format}, 170 | .ql-picker.ql-{format} .ql-picker-label, 171 | .ql-picker .ql-picker-label[data-value={format}], 172 | .ql-picker .ql-picker-item[data-value={format}] 173 | background-image: url('assets/' + format + suffix + '.png') 174 | 175 | .ql-snow.ql-toolbar .ql-format-button.ql-{format}.ql-active, 176 | .ql-snow.ql-toolbar .ql-picker.ql-{format} .ql-picker-label.ql-active, 177 | .ql-snow.ql-toolbar .ql-picker .ql-picker-label[data-value={format}].ql-active, 178 | .ql-snow.ql-toolbar .ql-picker .ql-picker-item[data-value={format}].ql-selected, 179 | .ql-snow.ql-toolbar:not(.ios) .ql-format-button.ql-{format}:hover, 180 | .ql-snow.ql-toolbar:not(.ios) .ql-picker.ql-{format} .ql-picker-label:hover, 181 | .ql-snow.ql-toolbar:not(.ios) .ql-picker .ql-picker-label[data-value={format}]:hover, 182 | .ql-snow.ql-toolbar:not(.ios) .ql-picker .ql-picker-item[data-value={format}]:hover 183 | background-image: url('assets/active/' + format + suffix + '.png') 184 | 185 | imageRules(false) 186 | 187 | @media (-webkit-min-device-pixel-ratio: 2) 188 | imageRules(true) 189 | -------------------------------------------------------------------------------- /src/modules/toolbar.coffee: -------------------------------------------------------------------------------- 1 | Quill = require('../quill') 2 | _ = Quill.require('lodash') 3 | dom = Quill.require('dom') 4 | 5 | 6 | class Toolbar 7 | @DEFAULTS: 8 | container: null 9 | 10 | @formats: 11 | LINE : { 'align', 'bullet', 'list' } 12 | SELECT : { 'align', 'background', 'color', 'font', 'size' } 13 | TOGGLE : { 'bold', 'bullet', 'image', 'italic', 'link', 'list', 'strike', 'underline' } 14 | TOOLTIP : { 'image', 'link' } 15 | 16 | constructor: (@quill, @options) -> 17 | @options = { container: @options } if _.isString(@options) or _.isElement(@options) 18 | throw new Error('container required for toolbar', @options) unless @options.container? 19 | @container = if _.isString(@options.container) then document.querySelector(@options.container) else @options.container 20 | @inputs = {} 21 | @preventUpdate = false 22 | @triggering = false 23 | _.each(@quill.options.formats, (name) => 24 | return if Toolbar.formats.TOOLTIP[name]? 25 | this.initFormat(name, _.bind(this._applyFormat, this, name)) 26 | ) 27 | @quill.on(Quill.events.FORMAT_INIT, (name) => 28 | return if Toolbar.formats.TOOLTIP[name]? 29 | this.initFormat(name, _.bind(this._applyFormat, this, name)) 30 | ) 31 | @quill.on(Quill.events.SELECTION_CHANGE, (range) => 32 | this.updateActive(range) if range? 33 | ) 34 | @quill.on(Quill.events.TEXT_CHANGE, => this.updateActive()) 35 | @quill.onModuleLoad('keyboard', (keyboard) => 36 | keyboard.addHotkey([dom.KEYS.BACKSPACE, dom.KEYS.DELETE], => 37 | _.defer(_.bind(this.updateActive, this)) 38 | ) 39 | ) 40 | dom(@container).addClass('ql-toolbar') 41 | dom(@container).addClass('ios') if dom.isIOS() # Fix for iOS not losing hover state after click 42 | 43 | initFormat: (format, callback) -> 44 | selector = ".ql-#{format}" 45 | if Toolbar.formats.SELECT[format]? 46 | selector = "select#{selector}" # Avoid selecting the picker container 47 | eventName = 'change' 48 | else 49 | eventName = 'click' 50 | input = @container.querySelector(selector) 51 | return unless input? 52 | @inputs[format] = input 53 | dom(input).on(eventName, => 54 | value = if eventName == 'change' then dom(input).value() else !dom(input).hasClass('ql-active') 55 | @preventUpdate = true 56 | @quill.focus() 57 | range = @quill.getSelection() 58 | callback(range, value) if range? 59 | @quill.editor.selection.scrollIntoView() if dom.isIE(11) 60 | @preventUpdate = false 61 | return false 62 | ) 63 | 64 | setActive: (format, value) -> 65 | value = false if format == 'image' # TODO generalize to all embeds 66 | input = @inputs[format] 67 | return unless input? 68 | $input = dom(input) 69 | if input.tagName == 'SELECT' 70 | @triggering = true 71 | selectValue = $input.value(input) 72 | value = $input.default()?.value unless value? 73 | value = '' if Array.isArray(value) # Must be a defined falsy value 74 | if value != selectValue 75 | if value? 76 | $input.option(value) 77 | else 78 | $input.reset() 79 | @triggering = false 80 | else 81 | $input.toggleClass('ql-active', value or false) 82 | 83 | updateActive: (range, formats = null) -> 84 | range or= @quill.getSelection() 85 | return unless range? and !@preventUpdate 86 | activeFormats = this._getActive(range) 87 | _.each(@inputs, (input, format) => 88 | if !Array.isArray(formats) or formats.indexOf(format) > -1 89 | this.setActive(format, activeFormats[format]) 90 | return true 91 | ) 92 | 93 | _applyFormat: (format, range, value) -> 94 | return if @triggering 95 | if range.isCollapsed() 96 | @quill.prepareFormat(format, value, 'user') 97 | else if Toolbar.formats.LINE[format]? 98 | @quill.formatLine(range, format, value, 'user') 99 | else 100 | @quill.formatText(range, format, value, 'user') 101 | _.defer( => 102 | this.updateActive(range, ['bullet', 'list']) # Clear exclusive formats 103 | this.setActive(format, value) 104 | ) 105 | 106 | _getActive: (range) -> 107 | leafFormats = this._getLeafActive(range) 108 | lineFormats = this._getLineActive(range) 109 | return _.defaults({}, leafFormats, lineFormats) 110 | 111 | _getLeafActive: (range) -> 112 | if range.isCollapsed() 113 | [line, offset] = @quill.editor.doc.findLineAt(range.start) 114 | if offset == 0 115 | contents = @quill.getContents(range.start, range.end + 1) 116 | else 117 | contents = @quill.getContents(range.start - 1, range.end) 118 | else 119 | contents = @quill.getContents(range) 120 | formatsArr = _.map(contents.ops, 'attributes') 121 | return this._intersectFormats(formatsArr) 122 | 123 | _getLineActive: (range) -> 124 | formatsArr = [] 125 | [firstLine, offset] = @quill.editor.doc.findLineAt(range.start) 126 | [lastLine, offset] = @quill.editor.doc.findLineAt(range.end) 127 | lastLine = lastLine.next if lastLine? and lastLine == firstLine 128 | while firstLine? and firstLine != lastLine 129 | formatsArr.push(_.clone(firstLine.formats)) 130 | firstLine = firstLine.next 131 | return this._intersectFormats(formatsArr) 132 | 133 | _intersectFormats: (formatsArr) -> 134 | return _.reduce(formatsArr.slice(1), (activeFormats, formats = {}) -> 135 | activeKeys = Object.keys(activeFormats) 136 | formatKeys = if formats? then Object.keys(formats) else {} 137 | intersection = _.intersection(activeKeys, formatKeys) 138 | missing = _.difference(activeKeys, formatKeys) 139 | added = _.difference(formatKeys, activeKeys) 140 | _.each(intersection, (name) -> 141 | if Toolbar.formats.SELECT[name]? 142 | if Array.isArray(activeFormats[name]) 143 | activeFormats[name].push(formats[name]) if activeFormats[name].indexOf(formats[name]) < 0 144 | else if activeFormats[name] != formats[name] 145 | activeFormats[name] = [activeFormats[name], formats[name]] 146 | ) 147 | _.each(missing, (name) -> 148 | if Toolbar.formats.TOGGLE[name]? 149 | delete activeFormats[name] 150 | else if Toolbar.formats.SELECT[name]? and !Array.isArray(activeFormats[name]) 151 | activeFormats[name] = [activeFormats[name]] 152 | ) 153 | _.each(added, (name) -> 154 | activeFormats[name] = [formats[name]] if Toolbar.formats.SELECT[name]? 155 | ) 156 | return activeFormats 157 | , formatsArr[0] or {}) 158 | 159 | 160 | Quill.registerModule('toolbar', Toolbar) 161 | module.exports = Toolbar 162 | -------------------------------------------------------------------------------- /test/unit/modules/toolbar.coffee: -------------------------------------------------------------------------------- 1 | dom = Quill.Lib.DOM 2 | 3 | describe('Toolbar', -> 4 | beforeEach( -> 5 | jasmine.resetEditor() 6 | @editorContainer = $('#editor-container').html(' 7 |
8 |
9 | 0123456789 10 |
11 |
12 | ').get(0) 13 | @toolbarContainer = $('#toolbar-container').get(0) 14 | @toolbarContainer.innerHTML = @toolbarContainer.innerHTML # Remove child listeners 15 | @quill = new Quill(@editorContainer.firstChild) 16 | @toolbar = @quill.addModule('toolbar', { container: @toolbarContainer }) 17 | @button = @toolbarContainer.querySelector('.ql-bold') 18 | @select = @toolbarContainer.querySelector('.ql-size') 19 | ) 20 | 21 | afterEach((done) -> 22 | clearInterval(@quill.editor.timer) 23 | _.defer(done) 24 | ) 25 | 26 | describe('format', -> 27 | it('button add', -> 28 | range = new Quill.Lib.Range(2, 4) 29 | @quill.setSelection(range) 30 | dom(@button).trigger('click') 31 | expect(@quill.getContents(range)).toEqualDelta(new Quill.Delta().insert('23', { bold: true })) 32 | ) 33 | 34 | it('button remove', -> 35 | range = new Quill.Lib.Range(0, 2) 36 | @quill.setSelection(range) 37 | dom(@button).trigger('click') 38 | expect(@quill.getContents(range)).toEqualDelta(new Quill.Delta().insert('01')) 39 | ) 40 | 41 | it('dropdown add', -> 42 | range = new Quill.Lib.Range(2, 4) 43 | @quill.setSelection(range) 44 | dom(@select).option('18px') 45 | expect(@quill.getContents(range)).toEqualDelta(new Quill.Delta().insert('23', { size: '18px' })) 46 | ) 47 | 48 | it('dropdown remove', -> 49 | range = new Quill.Lib.Range(6, 8) 50 | @quill.setSelection(range) 51 | dom(@select).reset() 52 | expect(@quill.getContents(range)).toEqualDelta(new Quill.Delta().insert('67')) 53 | ) 54 | ) 55 | 56 | describe('updateActive()', -> 57 | it('button', -> 58 | @quill.setSelection(1, 1) 59 | expect(dom(@button).hasClass('ql-active')).toBe(true) 60 | ) 61 | 62 | it('dropdown', -> 63 | @quill.setSelection(7, 7) 64 | expect(dom(@select).value()).toEqual('18px') 65 | ) 66 | 67 | it('dropdown change', -> 68 | @quill.setSelection(7, 7) 69 | @quill.setSelection(9, 9) 70 | expect(dom(@select).value()).toEqual('32px') 71 | ) 72 | 73 | it('dropdown reset', -> 74 | @quill.setSelection(7, 7) 75 | @quill.setSelection(3, 3) 76 | expect(dom(@select).value()).toEqual('13px') 77 | ) 78 | 79 | it('dropdown blank', -> 80 | @quill.setSelection(5, 7) 81 | expect(dom(@select).value()).toEqual('') 82 | ) 83 | ) 84 | 85 | describe('_getActive()', -> 86 | tests = 87 | 'cursor in middle of format': 88 | range: [1, 1], expected: { bold: true } 89 | 'cursor at beginning of format': 90 | range: [4, 4], expected: {} 91 | 'cursor at end of format': 92 | range: [2, 2], expected: { bold: true } 93 | 'neighboring formats': 94 | range: [2, 4], expected: {} 95 | 'overlapping formats': 96 | range: [1, 3], expected: {} 97 | 'select format': 98 | range: [7, 7], expected: { size: '18px' } 99 | 'overlapping select formats': 100 | range: [5, 7], expected: { size: ['18px'] } 101 | 102 | _.each(tests, (test, name) -> 103 | it(name, -> 104 | formats = @toolbar._getActive(new Quill.Lib.Range(test.range[0], test.range[1])) 105 | expect(formats).toEqual(test.expected) 106 | ) 107 | ) 108 | ) 109 | 110 | describe('_interesctFormats()', -> 111 | tests = 112 | 'preserve common format': 113 | initial: [{ bold: true }, { bold: true }] 114 | expected: { bold: true } 115 | 'remove uncommon format': 116 | initial: [{ bold: true }, { italic: true }] 117 | expected: {} 118 | 'common select format': 119 | initial: [{ size: '18px' }, { size: '18px' }] 120 | expected: { size: '18px' } 121 | 'combine select format': 122 | initial: [{ size: '18px' }, { size: '10px' }, { size: '32px' }] 123 | expected: { size: ['18px', '10px', '32px'] } 124 | 'preserve select format': 125 | initial: [{ bold: true }, { size: '18px' }, { italic: true }] 126 | expected: { size: ['18px'] } 127 | 'combination of all cases': 128 | initial: [{ bold: true, size: '10px' }, { bold: true, italic: true }, { bold: true, size: '18px' }] 129 | expected: { bold: true, size: ['10px', '18px'] } 130 | 131 | _.each(tests, (test, name) -> 132 | it(name, -> 133 | formats = @toolbar._intersectFormats(test.initial) 134 | expect(formats).toEqual(test.expected) 135 | ) 136 | ) 137 | ) 138 | 139 | describe('quill content methods', -> 140 | beforeEach( -> 141 | @quill.setSelection(1, 1) 142 | ) 143 | 144 | it('deleteText()', -> 145 | expect(dom(@button).hasClass('ql-active')).toBe(true) 146 | @quill.deleteText(0, 2) 147 | expect(dom(@button).hasClass('ql-active')).toBe(false) 148 | ) 149 | 150 | it('insertEmbed()', -> 151 | @quill.addModule('image-tooltip', true) 152 | image = @toolbarContainer.querySelector('.ql-image') 153 | expect(dom(@button).hasClass('ql-active')).toBe(true) 154 | expect(dom(image).hasClass('ql-active')).toBe(false) 155 | @quill.insertEmbed(1, 'image', 'http://quilljs.com/images/cloud.png') 156 | expect(dom(@button).hasClass('ql-active')).toBe(false) 157 | expect(dom(image).hasClass('ql-active')).toBe(false) 158 | ) 159 | 160 | it('insertText()', -> 161 | expect(dom(@button).hasClass('ql-active')).toBe(true) 162 | @quill.insertText(1, 'not-bold', 'bold', false) 163 | expect(dom(@button).hasClass('ql-active')).toBe(false) 164 | ) 165 | 166 | it('setText()', -> 167 | expect(dom(@button).hasClass('ql-active')).toBe(true) 168 | @quill.setText('plain text') 169 | expect(dom(@button).hasClass('ql-active')).toBe(false) 170 | ) 171 | 172 | it('setHTML()', -> 173 | italic = @toolbarContainer.querySelector('.ql-italic') 174 | expect(dom(@button).hasClass('ql-active')).toBe(true) 175 | expect(dom(italic).hasClass('ql-active')).toBe(false) 176 | @quill.setHTML('italicized') 177 | expect(dom(@button).hasClass('ql-active')).toBe(false) 178 | expect(dom(italic).hasClass('ql-active')).toBe(true) 179 | ) 180 | 181 | it('formatText()', -> 182 | expect(dom(@button).hasClass('ql-active')).toBe(true) 183 | @quill.formatText(0, 1, 'bold', false) 184 | expect(dom(@button).hasClass('ql-active')).toBe(false) 185 | ) 186 | ) 187 | ) 188 | -------------------------------------------------------------------------------- /src/core/normalizer.coffee: -------------------------------------------------------------------------------- 1 | _ = require('lodash') 2 | dom = require('../lib/dom') 3 | 4 | 5 | camelize = (str) -> 6 | str = str.replace(/(?:^|[-_])(\w)/g, (i, c) -> 7 | return if c then c.toUpperCase() else '' 8 | ) 9 | return str.charAt(0).toLowerCase() + str.slice(1) 10 | 11 | 12 | class Normalizer 13 | @ALIASES: { 14 | 'STRONG' : 'B' 15 | 'EM' : 'I' 16 | 'DEL' : 'S' 17 | 'STRIKE' : 'S' 18 | } 19 | 20 | @ATTRIBUTES: { 21 | 'color': 'color' 22 | 'face' : 'fontFamily' 23 | 'size' : 'fontSize' 24 | } 25 | 26 | constructor: -> 27 | @whitelist = 28 | styles: {} 29 | tags: {} 30 | @whitelist.tags[dom.DEFAULT_BREAK_TAG] = true 31 | @whitelist.tags[dom.DEFAULT_BLOCK_TAG] = true 32 | @whitelist.tags[dom.DEFAULT_INLINE_TAG] = true 33 | 34 | addFormat: (config) -> 35 | @whitelist.tags[config.tag] = true if config.tag? 36 | @whitelist.tags[config.parentTag] = true if config.parentTag? 37 | @whitelist.styles[config.style] = true if config.style? 38 | 39 | normalizeLine: (lineNode) -> 40 | lineNode = Normalizer.wrapInline(lineNode) 41 | lineNode = Normalizer.handleBreaks(lineNode) 42 | if lineNode.tagName == 'LI' 43 | Normalizer.flattenList(lineNode) 44 | lineNode = Normalizer.pullBlocks(lineNode) 45 | lineNode = this.normalizeNode(lineNode) 46 | Normalizer.unwrapText(lineNode) 47 | lineNode = lineNode.firstChild if lineNode? and dom.LIST_TAGS[lineNode.tagName]? 48 | return lineNode 49 | 50 | normalizeNode: (node) -> 51 | return node if dom(node).isTextNode() 52 | _.each(Normalizer.ATTRIBUTES, (style, attribute) -> 53 | if node.hasAttribute(attribute) 54 | value = node.getAttribute(attribute) 55 | value = dom.convertFontSize(value) if attribute == 'size' 56 | node.style[style] = value 57 | node.removeAttribute(attribute) 58 | ) 59 | # Chrome turns into style in some cases 60 | if (node.style.fontWeight == 'bold' or node.style.fontWeight > 500) 61 | node.style.fontWeight = '' 62 | dom(node).wrap(document.createElement('b')) 63 | node = node.parentNode 64 | this.whitelistStyles(node) 65 | return this.whitelistTags(node) 66 | 67 | whitelistStyles: (node) -> 68 | original = dom(node).styles() 69 | styles = _.omit(original, (value, key) => 70 | return !@whitelist.styles[camelize(key)]? 71 | ) 72 | if Object.keys(styles).length < Object.keys(original).length 73 | if Object.keys(styles).length > 0 74 | dom(node).styles(styles, true) 75 | else 76 | node.removeAttribute('style') 77 | 78 | whitelistTags: (node) -> 79 | return node unless dom(node).isElement() 80 | if Normalizer.ALIASES[node.tagName]? 81 | node = dom(node).switchTag(Normalizer.ALIASES[node.tagName]).get() 82 | else if !@whitelist.tags[node.tagName]? 83 | if dom.BLOCK_TAGS[node.tagName]? 84 | node = dom(node).switchTag(dom.DEFAULT_BLOCK_TAG).get() 85 | else if !node.hasAttributes() and node.firstChild? 86 | node = dom(node).unwrap() 87 | else 88 | node = dom(node).switchTag(dom.DEFAULT_INLINE_TAG).get() 89 | return node 90 | 91 | @flattenList: (listNode) -> 92 | ref = listNode.nextSibling 93 | innerItems = _.map(listNode.querySelectorAll('li')) 94 | innerItems.forEach((item) -> 95 | listNode.parentNode.insertBefore(item, ref) 96 | ref = item.nextSibling 97 | ) 98 | innerLists = _.map(listNode.querySelectorAll(Object.keys(dom.LIST_TAGS).join(','))) 99 | innerLists.forEach((list) -> 100 | dom(list).remove() 101 | ) 102 | 103 | # Make sure descendant break tags are not causing multiple lines to be rendered 104 | @handleBreaks: (lineNode) -> 105 | breaks = _.map(lineNode.querySelectorAll(dom.DEFAULT_BREAK_TAG)) 106 | _.each(breaks, (br) => 107 | if br.nextSibling? and (!dom.isIE(10) or br.previousSibling?) 108 | dom(br.nextSibling).splitBefore(lineNode.parentNode) 109 | ) 110 | return lineNode 111 | 112 | # Removes unnecessary tags but does not modify line contents 113 | @optimizeLine: (lineNode) -> 114 | lineNode.normalize() 115 | lineNodeLength = dom(lineNode).length() 116 | nodes = dom(lineNode).descendants() 117 | while nodes.length > 0 118 | node = nodes.pop() 119 | continue unless node?.parentNode? 120 | continue if dom.EMBED_TAGS[node.tagName]? 121 | if node.tagName == dom.DEFAULT_BREAK_TAG 122 | # Remove unneeded BRs 123 | dom(node).remove() unless lineNodeLength == 0 124 | else if dom(node).length() == 0 125 | nodes.push(node.nextSibling) 126 | dom(node).unwrap() 127 | else if node.previousSibling? and node.tagName == node.previousSibling.tagName 128 | # Merge similar nodes 129 | if _.isEqual(dom(node).attributes(), dom(node.previousSibling).attributes()) 130 | nodes.push(node.firstChild) 131 | dom(node.previousSibling).merge(node) 132 | 133 | # Make sure descendants are all inline elements 134 | @pullBlocks: (lineNode) -> 135 | curNode = lineNode.firstChild 136 | while curNode? 137 | if dom.BLOCK_TAGS[curNode.tagName]? and curNode.tagName != 'LI' and curNode.tagName != 'IFRAME' 138 | dom(curNode).isolate(lineNode.parentNode) 139 | if (!dom.LIST_TAGS[curNode.tagName]? or !curNode.firstChild) and curNode.tagName != 'IFRAME' 140 | dom(curNode).unwrap() 141 | Normalizer.pullBlocks(lineNode) 142 | else 143 | dom(curNode.parentNode).unwrap() 144 | lineNode = curNode unless lineNode.parentNode? # May have just unwrapped lineNode 145 | break 146 | curNode = curNode.nextSibling 147 | return lineNode 148 | 149 | @stripComments: (html) -> 150 | return html.replace(//g, '') 151 | 152 | @stripWhitespace: (html) -> 153 | html = html.trim() 154 | # Replace all newline characters 155 | html = html.replace(/(\r?\n|\r)+/g, ' ') 156 | # Remove whitespace between tags, requires   for legitmate spaces 157 | html = html.replace(/\>\s+\<') 158 | return html 159 | 160 | # Wrap inline nodes with block tags 161 | @wrapInline: (lineNode) -> 162 | return lineNode if dom.BLOCK_TAGS[lineNode.tagName]? 163 | blockNode = document.createElement(dom.DEFAULT_BLOCK_TAG) 164 | lineNode.parentNode.insertBefore(blockNode, lineNode) 165 | while lineNode? and !dom.BLOCK_TAGS[lineNode.tagName]? 166 | nextNode = lineNode.nextSibling 167 | blockNode.appendChild(lineNode) 168 | lineNode = nextNode 169 | return blockNode 170 | 171 | @unwrapText: (lineNode) -> 172 | spans = _.map(lineNode.querySelectorAll(dom.DEFAULT_INLINE_TAG)) 173 | _.each(spans, (span) -> 174 | dom(span).unwrap() if (!span.hasAttributes()) 175 | ) 176 | 177 | 178 | module.exports = Normalizer 179 | -------------------------------------------------------------------------------- /src/core/format.coffee: -------------------------------------------------------------------------------- 1 | _ = require('lodash') 2 | dom = require('../lib/dom') 3 | 4 | 5 | class Format 6 | @types: 7 | LINE: 'line' 8 | EMBED: 'embed' 9 | 10 | @FORMATS: 11 | bold: 12 | tag: 'B' 13 | prepare: 'bold' 14 | 15 | italic: 16 | tag: 'I' 17 | prepare: 'italic' 18 | 19 | underline: 20 | tag: 'U' 21 | prepare: 'underline' 22 | 23 | strike: 24 | tag: 'S' 25 | prepare: 'strikeThrough' 26 | 27 | color: 28 | style: 'color' 29 | default: 'rgb(0, 0, 0)' 30 | prepare: 'foreColor' 31 | 32 | background: 33 | style: 'backgroundColor' 34 | default: 'rgb(255, 255, 255)' 35 | prepare: 'backColor' 36 | 37 | font: 38 | style: 'fontFamily' 39 | default: "'Helvetica', 'Arial', sans-serif" 40 | prepare: 'fontName' 41 | 42 | size: 43 | style: 'fontSize' 44 | default: '13px' 45 | prepare: (value) -> 46 | document.execCommand('fontSize', false, dom.convertFontSize(value)) 47 | 48 | link: 49 | tag: 'A' 50 | add: (node, value) -> 51 | node.setAttribute('href', value) 52 | return node 53 | remove: (node) -> 54 | node.removeAttribute('href') 55 | return node 56 | value: (node) -> 57 | return node.getAttribute('href') 58 | 59 | image: 60 | type: Format.types.EMBED 61 | tag: 'IMG' 62 | attribute: 'src' 63 | 64 | video: 65 | tag: 'IFRAME' 66 | attribute: 'src' 67 | 68 | align: 69 | type: Format.types.LINE 70 | style: 'textAlign' 71 | default: 'left' 72 | 73 | bullet: 74 | type: Format.types.LINE 75 | exclude: 'list' 76 | parentTag: 'UL' 77 | tag: 'LI' 78 | 79 | list: 80 | type: Format.types.LINE 81 | exclude: 'bullet' 82 | parentTag: 'OL' 83 | tag: 'LI' 84 | 85 | 86 | constructor: (@config) -> 87 | 88 | add: (node, value) -> 89 | return this.remove(node) unless value 90 | return node if this.value(node) == value 91 | if _.isString(@config.parentTag) 92 | parentNode = node.parentNode; 93 | if parentNode.tagName != @config.parentTag 94 | parentNode = document.createElement(@config.parentTag) 95 | dom(node).wrap(parentNode) 96 | if node.parentNode.tagName == node.parentNode.previousSibling?.tagName 97 | dom(node.parentNode.previousSibling).merge(node.parentNode) 98 | if node.parentNode.tagName == node.parentNode.nextSibling?.tagName 99 | dom(node.parentNode).merge(node.parentNode.nextSibling) 100 | if _.isString(@config.tag) and node.tagName != @config.tag 101 | formatNode = document.createElement(@config.tag) 102 | if dom.VOID_TAGS[formatNode.tagName]? 103 | dom(node).replace(formatNode) if node.parentNode? 104 | node = formatNode 105 | else if this.isType(Format.types.LINE) 106 | node = dom(node).switchTag(@config.tag).get() 107 | else 108 | dom(node).wrap(formatNode) 109 | node = formatNode 110 | if _.isString(@config.style) or _.isString(@config.attribute) or _.isString(@config.class) 111 | if _.isString(@config.class) 112 | node = this.remove(node) 113 | if dom(node).isTextNode() 114 | inline = document.createElement(dom.DEFAULT_INLINE_TAG) 115 | dom(node).wrap(inline) 116 | node = inline 117 | if _.isString(@config.style) 118 | node.style[@config.style] = value if value != @config.default 119 | if _.isString(@config.attribute) 120 | node.setAttribute(@config.attribute, value) 121 | if _.isString(@config.class) 122 | dom(node).addClass(@config.class + value) 123 | if _.isFunction(@config.add) 124 | node = @config.add(node, value) 125 | return node 126 | 127 | isType: (type) -> 128 | return type == @config.type 129 | 130 | match: (node) -> 131 | return false unless dom(node).isElement() 132 | if _.isString(@config.parentTag) and node.parentNode?.tagName != @config.parentTag 133 | return false 134 | if _.isString(@config.tag) and node.tagName != @config.tag 135 | return false 136 | if _.isString(@config.style) and (!node.style[@config.style] or node.style[@config.style] == @config.default) 137 | return false 138 | if _.isString(@config.attribute) and !node.hasAttribute(@config.attribute) 139 | return false 140 | if _.isString(@config.class) 141 | for c in dom(node).classes() 142 | return true if c.indexOf(@config.class) == 0 143 | return false 144 | return true 145 | 146 | prepare: (value) -> 147 | if _.isString(@config.prepare) 148 | document.execCommand(@config.prepare, false, value) 149 | else if _.isFunction(@config.prepare) 150 | @config.prepare(value) 151 | 152 | remove: (node) -> 153 | return node unless this.match(node) 154 | if _.isString(@config.style) 155 | node.style[@config.style] = '' # IE10 requires setting to '', other browsers can take null 156 | node.removeAttribute('style') unless node.getAttribute('style') # Some browsers leave empty style attribute 157 | if _.isString(@config.attribute) 158 | node.removeAttribute(@config.attribute) 159 | if _.isString(@config.class) 160 | for c in dom(node).classes() 161 | dom(node).removeClass(c) if c.indexOf(@config.class) == 0 162 | if _.isString(@config.tag) 163 | if this.isType(Format.types.LINE) 164 | if _.isString(@config.parentTag) 165 | dom(node).splitBefore(node.parentNode.parentNode) if node.previousSibling? 166 | dom(node.nextSibling).splitBefore(node.parentNode.parentNode) if node.nextSibling? 167 | node = dom(node).switchTag(dom.DEFAULT_BLOCK_TAG).get() 168 | else if this.isType(Format.types.EMBED) 169 | dom(node).remove() 170 | return undefined 171 | else 172 | node = dom(node).switchTag(dom.DEFAULT_INLINE_TAG).get() 173 | if _.isString(@config.parentTag) 174 | dom(node.parentNode).unwrap() 175 | if _.isFunction(@config.remove) 176 | node = @config.remove(node) 177 | if node.tagName == dom.DEFAULT_INLINE_TAG and !node.hasAttributes() 178 | node = dom(node).unwrap() 179 | return node 180 | 181 | value: (node) -> 182 | return undefined unless this.match(node) 183 | if @config.value 184 | return @config.value(node) 185 | if _.isString(@config.attribute) 186 | return node.getAttribute(@config.attribute) or undefined # So "" does not get returned 187 | else if _.isString(@config.style) 188 | return node.style[@config.style] or undefined 189 | else if _.isString(@config.class) 190 | for c in dom(node).classes() 191 | return c.slice(@config.class.length) if c.indexOf(@config.class) == 0 192 | else if _.isString(@config.tag) 193 | return true 194 | return undefined 195 | 196 | 197 | module.exports = Format 198 | -------------------------------------------------------------------------------- /src/core/line.coffee: -------------------------------------------------------------------------------- 1 | _ = require('lodash') 2 | Delta = require('rich-text/lib/delta') 3 | dom = require('../lib/dom') 4 | Format = require('./format') 5 | Leaf = require('./leaf') 6 | Line = require('./line') 7 | LinkedList = require('../lib/linked-list') 8 | Normalizer = require('./normalizer') 9 | 10 | 11 | class Line extends LinkedList.Node 12 | @DATA_KEY : 'line' 13 | 14 | constructor: (@doc, @node) -> 15 | @formats = {} 16 | this.rebuild() 17 | super(@node) 18 | 19 | buildLeaves: (node, formats) -> 20 | _.each(dom(node).childNodes(), (node) => 21 | node = @doc.normalizer.normalizeNode(node) 22 | nodeFormats = _.clone(formats) 23 | # TODO: optimize 24 | _.each(@doc.formats, (format, name) -> 25 | # format.value() also checks match() but existing bug in tandem-core requires check anyways 26 | nodeFormats[name] = format.value(node) if !format.isType(Format.types.LINE) and format.match(node) 27 | ) 28 | if Leaf.isLeafNode(node) 29 | @leaves.append(new Leaf(node, nodeFormats)) 30 | else 31 | this.buildLeaves(node, nodeFormats) 32 | ) 33 | 34 | deleteText: (offset, length) -> 35 | return unless length > 0 36 | [leaf, offset] = this.findLeafAt(offset) 37 | while leaf? and length > 0 38 | deleteLength = Math.min(length, leaf.length - offset) 39 | leaf.deleteText(offset, deleteLength) 40 | length -= deleteLength 41 | leaf = leaf.next 42 | offset = 0 43 | this.rebuild() 44 | 45 | findLeaf: (leafNode) -> 46 | return if leafNode? then dom(leafNode).data(Leaf.DATA_KEY) else undefined 47 | 48 | findLeafAt: (offset, inclusive = false) -> 49 | # TODO exact same code as findLineAt 50 | return [@leaves.last, @leaves.last.length] if offset >= @length - 1 51 | leaf = @leaves.first 52 | while leaf? 53 | if offset < leaf.length or (offset == leaf.length and inclusive) 54 | return [leaf, offset] 55 | offset -= leaf.length 56 | leaf = leaf.next 57 | return [@leaves.last, offset - @leaves.last.length] # Should never occur unless length calculation is off 58 | 59 | format: (name, value) -> 60 | if _.isObject(name) 61 | formats = name 62 | else 63 | formats = {} 64 | formats[name] = value 65 | _.each(formats, (value, name) => 66 | format = @doc.formats[name] 67 | return unless format? 68 | # TODO reassigning @node might be dangerous... 69 | if format.isType(Format.types.LINE) 70 | if format.config.exclude and @formats[format.config.exclude] 71 | excludeFormat = @doc.formats[format.config.exclude] 72 | if excludeFormat? 73 | @node = excludeFormat.remove(@node) 74 | delete @formats[format.config.exclude] 75 | @node = format.add(@node, value) 76 | if value 77 | @formats[name] = value 78 | else 79 | delete @formats[name] 80 | ) 81 | this.resetContent() 82 | 83 | formatText: (offset, length, name, value) -> 84 | [leaf, leafOffset] = this.findLeafAt(offset) 85 | format = @doc.formats[name] 86 | return unless format? and format.config.type != Format.types.LINE 87 | while leaf? and length > 0 88 | nextLeaf = leaf.next 89 | # Make sure we need to change leaf format 90 | if (value and leaf.formats[name] != value) or (!value and leaf.formats[name]?) 91 | targetNode = leaf.node 92 | # Identify node to modify 93 | if leaf.formats[name]? 94 | dom(targetNode).splitBefore(@node) 95 | while !format.match(targetNode) 96 | targetNode = targetNode.parentNode 97 | dom(targetNode).split(leaf.length) 98 | # Isolate target node 99 | if leafOffset > 0 100 | [leftNode, targetNode] = dom(targetNode).split(leafOffset) 101 | if leaf.length > leafOffset + length # leaf.length does not update with split() 102 | [targetNode, rightNode] = dom(targetNode).split(length) 103 | format.add(targetNode, value) 104 | length -= leaf.length - leafOffset 105 | leafOffset = 0 106 | leaf = nextLeaf 107 | this.rebuild() 108 | 109 | _insert: (offset, node, formats) -> 110 | [leaf, leafOffset] = this.findLeafAt(offset) 111 | node = _.reduce(formats, (node, value, name) => 112 | format = @doc.formats[name] 113 | if format? and !format.isType(Format.types.LINE) 114 | node = format.add(node, value) 115 | return node 116 | , node) 117 | [prevNode, nextNode] = dom(leaf.node).split(leafOffset) 118 | nextNode = dom(nextNode).splitBefore(@node).get() if nextNode 119 | @node.insertBefore(node, nextNode) 120 | this.rebuild() 121 | 122 | insertEmbed: (offset, attributes) -> 123 | [leaf, leafOffset] = this.findLeafAt(offset) 124 | [prevNode, nextNode] = dom(leaf.node).split(leafOffset) 125 | formatName = _.find(Object.keys(attributes), (name) => 126 | return @doc.formats[name].isType(Format.types.EMBED) 127 | ) 128 | node = @doc.formats[formatName].add({}, attributes[formatName]) # TODO fix {} hack 129 | attributes = _.clone(attributes) 130 | delete attributes[formatName] 131 | this._insert(offset, node, attributes) 132 | 133 | insertText: (offset, text, formats = {}) -> 134 | return unless text.length > 0 135 | [leaf, leafOffset] = this.findLeafAt(offset) 136 | if _.isEqual(leaf.formats, formats) 137 | leaf.insertText(leafOffset, text) 138 | this.resetContent() 139 | else 140 | this._insert(offset, document.createTextNode(text), formats) 141 | 142 | optimize: -> 143 | Normalizer.optimizeLine(@node) 144 | this.rebuild() 145 | 146 | rebuild: (force = false) -> 147 | if !force and @outerHTML? and @outerHTML == @node.outerHTML 148 | if _.all(@leaves.toArray(), (leaf) => 149 | return dom(leaf.node).isAncestor(@node) 150 | ) 151 | return false 152 | @node = @doc.normalizer.normalizeNode(@node) 153 | if dom(@node).length() == 0 and !@node.querySelector(dom.DEFAULT_BREAK_TAG) 154 | @node.appendChild(document.createElement(dom.DEFAULT_BREAK_TAG)) 155 | @leaves = new LinkedList() 156 | @formats = _.reduce(@doc.formats, (formats, format, name) => 157 | if format.isType(Format.types.LINE) 158 | if format.match(@node) 159 | formats[name] = format.value(@node) 160 | else 161 | delete formats[name] 162 | return formats 163 | , @formats) 164 | this.buildLeaves(@node, {}) 165 | this.resetContent() 166 | return true 167 | 168 | resetContent: -> 169 | dom(@node).data(Line.DATA_KEY, this) 170 | @outerHTML = @node.outerHTML 171 | @length = 1 172 | @delta = new Delta() 173 | _.each(@leaves.toArray(), (leaf) => 174 | @length += leaf.length 175 | # TODO use constant for embed type 176 | if dom.EMBED_TAGS[leaf.node.tagName]? 177 | @delta.insert(1, leaf.formats) 178 | else 179 | @delta.insert(leaf.text, leaf.formats) 180 | ) 181 | @delta.insert('\n', @formats) 182 | 183 | 184 | module.exports = Line 185 | -------------------------------------------------------------------------------- /src/core/selection.coffee: -------------------------------------------------------------------------------- 1 | _ = require('lodash') 2 | dom = require('../lib/dom') 3 | Leaf = require('./leaf') 4 | Normalizer = require('./normalizer') 5 | Range = require('../lib/range') 6 | 7 | 8 | class Selection 9 | constructor: (@doc, @emitter) -> 10 | @focus = false 11 | @range = new Range(0, 0) 12 | @nullDelay = false 13 | this.update('silent') 14 | 15 | checkFocus: -> 16 | return document.activeElement == @doc.root 17 | 18 | getRange: (ignoreFocus = false) -> 19 | if this.checkFocus() 20 | nativeRange = this._getNativeRange() 21 | return null unless nativeRange? 22 | start = this._positionToIndex(nativeRange.startContainer, nativeRange.startOffset) 23 | if nativeRange.startContainer == nativeRange.endContainer and nativeRange.startOffset == nativeRange.endOffset 24 | end = start 25 | else 26 | end = this._positionToIndex(nativeRange.endContainer, nativeRange.endOffset) 27 | return new Range(Math.min(start, end), Math.max(start, end)) # Handle backwards ranges 28 | else if ignoreFocus 29 | return @range 30 | else 31 | return null 32 | 33 | preserve: (fn) -> 34 | nativeRange = this._getNativeRange() 35 | if nativeRange? and this.checkFocus() 36 | [startNode, startOffset] = this._encodePosition(nativeRange.startContainer, nativeRange.startOffset) 37 | [endNode, endOffset] = this._encodePosition(nativeRange.endContainer, nativeRange.endOffset) 38 | fn() 39 | [startNode, startOffset] = this._decodePosition(startNode, startOffset) 40 | [endNode, endOffset] = this._decodePosition(endNode, endOffset) 41 | this._setNativeRange(startNode, startOffset, endNode, endOffset) 42 | else 43 | fn() 44 | 45 | scrollIntoView: () -> 46 | return unless @range 47 | editor = @emitter.editor 48 | startBounds = editor.getBounds(@range.start) 49 | endBounds = if @range.isCollapsed() then startBounds else editor.getBounds(@range.end) 50 | containerBounds = editor.root.parentNode.getBoundingClientRect() 51 | containerHeight = containerBounds.bottom - containerBounds.top 52 | if containerHeight < endBounds.top + endBounds.height 53 | [line, offset] = editor.doc.findLineAt(@range.end) 54 | line.node.scrollIntoView(false) 55 | else if startBounds.top < 0 56 | [line, offset] = editor.doc.findLineAt(@range.start) 57 | line.node.scrollIntoView() 58 | 59 | setRange: (range, source) -> 60 | if range? 61 | [startNode, startOffset] = this._indexToPosition(range.start) 62 | if range.isCollapsed() 63 | [endNode, endOffset] = [startNode, startOffset] 64 | else 65 | [endNode, endOffset] = this._indexToPosition(range.end) 66 | this._setNativeRange(startNode, startOffset, endNode, endOffset) 67 | else 68 | this._setNativeRange(null) 69 | this.update(source) 70 | 71 | shiftAfter: (index, length, fn) -> 72 | range = this.getRange() 73 | fn() 74 | if range? 75 | range.shift(index, length) 76 | this.setRange(range, 'silent') 77 | 78 | update: (source) -> 79 | focus = this.checkFocus() 80 | range = this.getRange(true) 81 | emit = source != 'silent' and (!Range.compare(range, @range) or focus != @focus) 82 | toEmit = if focus then range else null 83 | # If range changes to null, require two update cycles to update and emit 84 | if toEmit == null and source == 'user' and !@nullDelay 85 | @nullDelay = true 86 | else 87 | @nullDelay = false 88 | @range = range 89 | @focus = focus 90 | # Set range before emitting to prevent infinite loop if listeners call quill.getSelection() 91 | @emitter.emit(@emitter.constructor.events.SELECTION_CHANGE, toEmit, source) if emit 92 | 93 | _decodePosition: (node, offset) -> 94 | if dom(node).isElement() 95 | childIndex = dom(node.parentNode).childNodes().indexOf(node) 96 | offset += childIndex 97 | node = node.parentNode 98 | return [node, offset] 99 | 100 | _encodePosition: (node, offset) -> 101 | while true 102 | if dom(node).isTextNode() or node.tagName == dom.DEFAULT_BREAK_TAG or dom.EMBED_TAGS[node.tagName]? 103 | return [node, offset] 104 | else if offset < node.childNodes.length 105 | node = node.childNodes[offset] 106 | offset = 0 107 | else if node.childNodes.length == 0 108 | # TODO revisit fix for encoding edge case

|

109 | unless @doc.normalizer.whitelist.tags[node.tagName]? 110 | text = document.createTextNode('') 111 | node.appendChild(text) 112 | node = text 113 | return [node, 0] 114 | else 115 | node = node.lastChild 116 | if dom(node).isElement() 117 | if node.tagName == dom.DEFAULT_BREAK_TAG or dom.EMBED_TAGS[node.tagName]? 118 | return [node, 1] 119 | else 120 | offset = node.childNodes.length 121 | else 122 | return [node, dom(node).length()] 123 | 124 | _getNativeRange: -> 125 | selection = document.getSelection() 126 | if selection?.rangeCount > 0 127 | range = selection.getRangeAt(0) 128 | if dom(range.startContainer).isAncestor(@doc.root, true) 129 | if range.startContainer == range.endContainer or dom(range.endContainer).isAncestor(@doc.root, true) 130 | return range 131 | return null 132 | 133 | _indexToPosition: (index) -> 134 | return [@doc.root, 0] if @doc.lines.length == 0 135 | [leaf, offset] = @doc.findLeafAt(index, true) 136 | return this._decodePosition(leaf.node, offset) 137 | 138 | _positionToIndex: (node, offset) -> 139 | offset = 0 if dom.isIE(10) and node.tagName == 'BR' and offset == 1 140 | [leafNode, offset] = this._encodePosition(node, offset) 141 | line = @doc.findLine(leafNode) 142 | # TODO move to linked list 143 | return 0 unless line? # Occurs on empty document 144 | leaf = line.findLeaf(leafNode) 145 | lineOffset = 0 146 | while line.prev? 147 | line = line.prev 148 | lineOffset += line.length 149 | return lineOffset unless leaf? 150 | leafOffset = 0 151 | while leaf.prev? 152 | leaf = leaf.prev 153 | leafOffset += leaf.length 154 | return lineOffset + leafOffset + offset 155 | 156 | _setNativeRange: (startNode, startOffset, endNode, endOffset) -> 157 | selection = document.getSelection() 158 | return unless selection 159 | if startNode? 160 | # Need to focus before setting or else in IE9/10 later focus will cause a set on 0th index on line div 161 | # to be set at 1st index 162 | @doc.root.focus() unless this.checkFocus() 163 | nativeRange = this._getNativeRange() 164 | if !nativeRange? or startNode != nativeRange.startContainer or startOffset != nativeRange.startOffset or endNode != nativeRange.endContainer or endOffset != nativeRange.endOffset 165 | # IE9 requires removeAllRanges() regardless of value of 166 | # nativeRange or else formatting from toolbar does not work 167 | selection.removeAllRanges() 168 | nativeRange = document.createRange() 169 | nativeRange.setStart(startNode, startOffset) 170 | nativeRange.setEnd(endNode, endOffset) 171 | selection.addRange(nativeRange) 172 | else 173 | selection.removeAllRanges() 174 | @doc.root.blur() 175 | # setRange(null) will fail to blur in IE10/11 on Travis+SauceLabs (but not local VMs) 176 | document.body.focus() if dom.isIE(11) and !dom.isIE(9) 177 | 178 | 179 | module.exports = Selection 180 | -------------------------------------------------------------------------------- /src/core/editor.coffee: -------------------------------------------------------------------------------- 1 | _ = require('lodash') 2 | Delta = require('rich-text/lib/delta') 3 | dom = require('../lib/dom') 4 | Document = require('./document') 5 | Line = require('./line') 6 | Selection = require('./selection') 7 | 8 | 9 | class Editor 10 | @sources: 11 | API : 'api' 12 | SILENT : 'silent' 13 | USER : 'user' 14 | 15 | constructor: (@root, @quill, @options = {}) -> 16 | @root.setAttribute('id', @options.id) 17 | @doc = new Document(@root, @options) 18 | @delta = @doc.toDelta() 19 | @length = @delta.length() 20 | @selection = new Selection(@doc, @quill) 21 | @timer = setInterval(_.bind(this.checkUpdate, this), @options.pollInterval) 22 | @savedRange = null; 23 | @quill.on("selection-change", (range) => 24 | @savedRange = range 25 | ) 26 | this.enable() unless @options.readOnly 27 | 28 | destroy: -> 29 | clearInterval(@timer) 30 | 31 | disable: -> 32 | this.enable(false) 33 | 34 | enable: (enabled = true) -> 35 | @root.setAttribute('contenteditable', enabled) 36 | 37 | applyDelta: (delta, source) -> 38 | localDelta = this._update() 39 | if localDelta 40 | delta = localDelta.transform(delta, true) 41 | localDelta = delta.transform(localDelta, false) 42 | if delta.ops.length > 0 43 | delta = this._trackDelta( => 44 | index = 0 45 | _.each(delta.ops, (op) => 46 | if _.isString(op.insert) 47 | this._insertAt(index, op.insert, op.attributes) 48 | index += op.insert.length; 49 | else if _.isNumber(op.insert) 50 | this._insertEmbed(index, op.attributes) 51 | index += 1; 52 | else if _.isNumber(op.delete) 53 | this._deleteAt(index, op.delete) 54 | else if _.isNumber(op.retain) 55 | _.each(op.attributes, (value, name) => 56 | this._formatAt(index, op.retain, name, value) 57 | ) 58 | index += op.retain 59 | ) 60 | @selection.shiftAfter(0, 0, _.bind(@doc.optimizeLines, @doc)) 61 | ) 62 | @delta = @doc.toDelta() 63 | @length = @delta.length() 64 | @innerHTML = @root.innerHTML 65 | @quill.emit(@quill.constructor.events.TEXT_CHANGE, delta, source) if delta and source != Editor.sources.SILENT 66 | if localDelta and localDelta.ops.length > 0 and source != Editor.sources.SILENT 67 | @quill.emit(@quill.constructor.events.TEXT_CHANGE, localDelta, Editor.sources.USER) 68 | 69 | checkUpdate: (source = 'user') -> 70 | return clearInterval(@timer) unless @root.parentNode? 71 | delta = this._update() 72 | if delta 73 | @delta = @delta.compose(delta) 74 | @length = @delta.length() 75 | @quill.emit(@quill.constructor.events.TEXT_CHANGE, delta, source) 76 | source = Editor.sources.SILENT if delta 77 | @selection.update(source) 78 | 79 | focus: -> 80 | if @selection.range? 81 | @selection.setRange(@selection.range) 82 | else 83 | @root.focus() 84 | 85 | getBounds: (index) -> 86 | this.checkUpdate() 87 | [leaf, offset] = @doc.findLeafAt(index, true) 88 | return null unless leaf? 89 | containerBounds = @root.parentNode.getBoundingClientRect() 90 | side = 'left' 91 | if leaf.length == 0 # BR case 92 | bounds = leaf.node.parentNode.getBoundingClientRect() 93 | else if dom.VOID_TAGS[leaf.node.tagName] 94 | bounds = leaf.node.getBoundingClientRect() 95 | side = 'right' if offset == 1 96 | else 97 | range = document.createRange() 98 | if offset < leaf.length 99 | range.setStart(leaf.node, offset) 100 | range.setEnd(leaf.node, offset + 1) 101 | else 102 | range.setStart(leaf.node, offset - 1) 103 | range.setEnd(leaf.node, offset) 104 | side = 'right' 105 | bounds = range.getBoundingClientRect() 106 | return { 107 | height: bounds.height 108 | left: bounds[side] - containerBounds.left 109 | top: bounds.top - containerBounds.top 110 | } 111 | 112 | _deleteAt: (index, length) -> 113 | return if length <= 0 114 | @selection.shiftAfter(index, -1 * length, => 115 | [firstLine, offset] = @doc.findLineAt(index) 116 | curLine = firstLine 117 | mergeFirstLine = firstLine.length - offset <= length and offset > 0 118 | while curLine? and length > 0 119 | nextLine = curLine.next 120 | deleteLength = Math.min(curLine.length - offset, length) 121 | if offset == 0 and length >= curLine.length 122 | @doc.removeLine(curLine) 123 | else 124 | curLine.deleteText(offset, deleteLength) 125 | length -= deleteLength 126 | curLine = nextLine 127 | offset = 0 128 | @doc.mergeLines(firstLine, firstLine.next) if mergeFirstLine and firstLine.next 129 | ) 130 | 131 | _formatAt: (index, length, name, value) -> 132 | @selection.shiftAfter(index, 0, => 133 | [line, offset] = @doc.findLineAt(index) 134 | while line? and length > 0 135 | formatLength = Math.min(length, line.length - offset - 1) 136 | line.formatText(offset, formatLength, name, value) 137 | length -= formatLength 138 | line.format(name, value) if length > 0 139 | length -= 1 140 | offset = 0 141 | line = line.next 142 | ) 143 | 144 | _insertEmbed: (index, attributes) -> 145 | @selection.shiftAfter(index, 1, => 146 | [line, offset] = @doc.findLineAt(index) 147 | line.insertEmbed(offset, attributes) 148 | ) 149 | 150 | _insertAt: (index, text, formatting = {}) -> 151 | @selection.shiftAfter(index, text.length, => 152 | text = text.replace(/\r\n?/g, '\n') 153 | lineTexts = text.split('\n') 154 | [line, offset] = @doc.findLineAt(index) 155 | _.each(lineTexts, (lineText, i) => 156 | if !line? or line.length <= offset # End of document 157 | if i < lineTexts.length - 1 or lineText.length > 0 158 | line = @doc.appendLine(document.createElement(dom.DEFAULT_BLOCK_TAG)) 159 | offset = 0 160 | line.insertText(offset, lineText, formatting) 161 | line.format(formatting) 162 | nextLine = null 163 | else 164 | line.insertText(offset, lineText, formatting) 165 | if i < lineTexts.length - 1 # Are there more lines to insert? 166 | nextLine = @doc.splitLine(line, offset + lineText.length) 167 | _.each(_.defaults({}, formatting, line.formats), (value, format) -> 168 | line.format(format, formatting[format]) 169 | ) 170 | offset = 0 171 | line = nextLine 172 | ) 173 | ) 174 | 175 | _trackDelta: (fn) -> 176 | oldIndex = @savedRange?.start 177 | fn() 178 | newDelta = @doc.toDelta() 179 | @savedRange = @selection.getRange() 180 | newIndex = @savedRange?.start 181 | try 182 | if oldIndex? and newIndex? and oldIndex <= @delta.length() and newIndex <= newDelta.length() 183 | oldRightDelta = @delta.slice(oldIndex) 184 | newRightDelta = newDelta.slice(newIndex) 185 | if _.isEqual(oldRightDelta.ops, newRightDelta.ops) 186 | oldLeftDelta = @delta.slice(0, oldIndex) 187 | newLeftDelta = newDelta.slice(0, newIndex) 188 | return oldLeftDelta.diff(newLeftDelta) 189 | catch ignored 190 | return @delta.diff(newDelta) 191 | 192 | _update: -> 193 | return false if @innerHTML == @root.innerHTML 194 | delta = this._trackDelta( => 195 | @selection.preserve(_.bind(@doc.rebuild, @doc)) 196 | @selection.shiftAfter(0, 0, _.bind(@doc.optimizeLines, @doc)) 197 | ) 198 | @innerHTML = @root.innerHTML 199 | return if delta.ops.length > 0 then delta else false 200 | 201 | 202 | module.exports = Editor 203 | -------------------------------------------------------------------------------- /test/unit/core/format.coffee: -------------------------------------------------------------------------------- 1 | describe('Format', -> 2 | beforeEach( -> 3 | @container = jasmine.clearContainer() 4 | ) 5 | 6 | tests = 7 | tag: 8 | format: new Quill.Format(Quill.Format.FORMATS.bold) 9 | existing: 'Text' 10 | missing: 'Text' 11 | value: true 12 | style: 13 | format: new Quill.Format(Quill.Format.FORMATS.color) 14 | existing: 'Text' 15 | missing: 'Text' 16 | value: 'blue' 17 | image: 18 | format: new Quill.Format(Quill.Format.FORMATS.image) 19 | existing: '' 20 | missing: '
' 21 | removed: '' 22 | value: 'http://quilljs.com/images/cloud.png' 23 | link: 24 | format: new Quill.Format(Quill.Format.FORMATS.link) 25 | existing: 'Text' 26 | missing: 'Text' 27 | value: 'http://quilljs.com' 28 | class: 29 | format: new Quill.Format({ class: 'author-' }) 30 | existing: 'Text' 31 | missing: 'Text' 32 | value: 'jason' 33 | line: 34 | format: new Quill.Format(Quill.Format.FORMATS.align) 35 | existing: '
Text
' 36 | missing: '
Text
' 37 | value: 'right' 38 | complex: 39 | format: new Quill.Format(Quill.Format.FORMATS.bold) 40 | existing: 'TextStrikeItalic' 41 | missing: 'TextStrikeItalic' 42 | value: true 43 | 44 | describe('match()', -> 45 | _.each(tests, (test, name) -> 46 | it("#{name} existing", -> 47 | @container.innerHTML = test.existing 48 | expect(test.format.match(@container.firstChild)).toBe(true) 49 | ) 50 | 51 | it("#{name} missing", -> 52 | @container.innerHTML = test.missing 53 | expect(test.format.match(@container.firstChild)).toBe(false) 54 | ) 55 | ) 56 | 57 | it("bullet existing", -> 58 | @container.innerHTML = '
  • One
  • Two
  • Three
' 59 | format = new Quill.Format(Quill.Format.FORMATS.bullet) 60 | li = @container.firstChild.childNodes[1] 61 | expect(format.match(li)).toBe(true) 62 | ) 63 | 64 | it("bullet missing", -> 65 | @container.innerHTML = '
  • One
Two
  • Three
' 66 | format = new Quill.Format(Quill.Format.FORMATS.bullet) 67 | li = @container.firstChild.childNodes[1] 68 | expect(format.match(li)).toBe(false) 69 | ) 70 | 71 | it('default', -> 72 | @container.innerHTML = 'Text' 73 | format = new Quill.Format(Quill.Format.FORMATS.color) 74 | expect(format.match(@container.firstChild)).toBe(false) 75 | ) 76 | ) 77 | 78 | describe('value()', -> 79 | _.each(tests, (test, name) -> 80 | it("#{name} existing", -> 81 | @container.innerHTML = test.existing 82 | expect(test.format.value(@container.firstChild)).toEqual(test.value) 83 | ) 84 | 85 | it("#{name} missing", -> 86 | @container.innerHTML = test.missing 87 | expect(test.format.value(@container.firstChild)).toBe(undefined) 88 | ) 89 | ) 90 | 91 | it('default', -> 92 | @container.innerHTML = 'Text' 93 | format = new Quill.Format(Quill.Format.FORMATS.color) 94 | expect(format.value(@container.firstChild)).toBe(undefined) 95 | ) 96 | 97 | it('bullets', -> 98 | @container.innerHTML = '
  • One
  • Two
  • Three
' 99 | format = new Quill.Format(Quill.Format.FORMATS.bullet) 100 | li = @container.firstChild.childNodes[1] 101 | expect(format.value(li)).toBe(true) 102 | ) 103 | ) 104 | 105 | describe('add()', -> 106 | _.each(tests, (test, name) -> 107 | it("#{name} add value", -> 108 | @container.innerHTML = test.missing 109 | test.format.add(@container.firstChild, test.value) 110 | expect(@container).toEqualHTML(test.added or test.existing) 111 | ) 112 | 113 | it("#{name} add value to exisitng", -> 114 | @container.innerHTML = test.existing 115 | test.format.add(@container.firstChild, test.value) 116 | expect(@container).toEqualHTML(test.existing) 117 | ) 118 | 119 | it("#{name} add falsy value to existing", -> 120 | @container.innerHTML = test.existing 121 | test.format.add(@container.firstChild, false) 122 | expected = if test.removed? then test.removed else test.missing 123 | expect(@container).toEqualHTML(expected) 124 | ) 125 | 126 | it("#{name} add falsy value to missing", -> 127 | @container.innerHTML = test.missing 128 | test.format.add(@container.firstChild, false) 129 | expect(@container).toEqualHTML(test.missing) 130 | ) 131 | ) 132 | 133 | it('change value', -> 134 | @container.innerHTML = 'Text' 135 | format = new Quill.Format(Quill.Format.FORMATS.color) 136 | format.add(@container.firstChild, 'red') 137 | expect(@container).toEqualHTML('Text') 138 | ) 139 | 140 | it('change value with given tag', -> 141 | @container.innerHTML = 'a' 142 | format = new Quill.Format(Quill.Format.FORMATS.link) 143 | format.add(@container.firstChild, 'link2') 144 | expect(@container).toEqualHTML('a') 145 | ) 146 | 147 | it('default value', -> 148 | @container.innerHTML = 'Text' 149 | format = new Quill.Format(Quill.Format.FORMATS.size) 150 | format.add(@container.firstChild, Quill.Format.FORMATS.size.default) 151 | expect(@container).toEqualHTML('Text') 152 | ) 153 | 154 | it('text node tag', -> 155 | @container.innerHTML = 'Text' 156 | format = new Quill.Format(Quill.Format.FORMATS.bold) 157 | format.add(@container.firstChild, true) 158 | expect(@container).toEqualHTML('Text') 159 | ) 160 | 161 | it('text node style', -> 162 | @container.innerHTML = 'Text' 163 | format = new Quill.Format(Quill.Format.FORMATS.size) 164 | format.add(@container.firstChild, '18px') 165 | expect(@container).toEqualHTML('Text') 166 | ) 167 | 168 | it('class over existing', -> 169 | @container.innerHTML = 'Text' 170 | format = new Quill.Format({ class: 'author-' }) 171 | format.add(@container.firstChild, 'jason') 172 | expect(@container).toEqualHTML('Text') 173 | ) 174 | 175 | it('bullets', -> 176 | @container.innerHTML = '
  • One
Two
  • Three
' 177 | format = new Quill.Format(Quill.Format.FORMATS.bullet) 178 | p = @container.childNodes[1] 179 | format.add(p, true) 180 | expect(@container).toEqualHTML('
  • One
  • Two
  • Three
') 181 | ) 182 | ) 183 | 184 | describe('remove()', -> 185 | _.each(tests, (test, name) -> 186 | it("#{name} existing", -> 187 | @container.innerHTML = test.existing 188 | test.format.remove(@container.firstChild) 189 | expected = if test.removed? then test.removed else test.missing 190 | expect(@container).toEqualHTML(expected) 191 | ) 192 | 193 | it("#{name} missing", -> 194 | @container.innerHTML = test.missing 195 | test.format.remove(@container.firstChild) 196 | expect(@container).toEqualHTML(test.missing) 197 | ) 198 | ) 199 | 200 | it('line format with parentTag', -> 201 | @container.innerHTML = '
  • One
  • Two
  • Three
' 202 | format = new Quill.Format(Quill.Format.FORMATS.bullet) 203 | li = @container.firstChild.childNodes[1] 204 | format.remove(li) 205 | expect(@container).toEqualHTML('
  • One
Two
  • Three
') 206 | ) 207 | 208 | it('line format without parentTag', -> 209 | @container.innerHTML = '
One

Two

Three
' 210 | format = new Quill.Format({ type: Quill.Format.types.LINE, tag: 'H1' }) 211 | line = @container.childNodes[1] 212 | format.remove(line) 213 | expect(@container).toEqualHTML('
One
Two
Three
') 214 | ) 215 | ) 216 | ) 217 | --------------------------------------------------------------------------------