├── 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 | '
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 | '
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 | '
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 = ''
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 | '
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/) [](http://travis-ci.org/quilljs/quill)
2 |
3 | [](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 |
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 = ''
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 = 'Two
'
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 = ''
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 = 'Two
'
177 | format = new Quill.Format(Quill.Format.FORMATS.bullet)
178 | p = @container.childNodes[1]
179 | format.add(p, true)
180 | expect(@container).toEqualHTML('')
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 = ''
202 | format = new Quill.Format(Quill.Format.FORMATS.bullet)
203 | li = @container.firstChild.childNodes[1]
204 | format.remove(li)
205 | expect(@container).toEqualHTML('Two
')
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 |
--------------------------------------------------------------------------------