diff --git a/src/iframe/iframeview.js b/src/iframe/iframeview.js new file mode 100644 index 0000000..ed8e92f --- /dev/null +++ b/src/iframe/iframeview.js @@ -0,0 +1,84 @@ +/** + * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +'use strict'; + +import View from '../view.js'; + +/** + * The basic iframe view class. + * + * @memberOf ui.iframe + * @extends ui.View + */ +export default class IframeView extends View { + /** + * Creates a new instance of the IframeView. + * + * @param {ui.iframe.IframeModel} [model] (View)Model of this IframeView. + * @param {utils.Locale} [locale] The {@link ckeditor5.Editor#locale editor's locale} instance. + */ + constructor( model, locale ) { + super( model, locale ); + + this.template = { + tag: 'iframe', + attributes: { + class: [ 'ck-reset-all' ], + // It seems that we need to allow scripts in order to be able to listen to events. + // TODO: Research that. Perhaps the src must be set? + sandbox: 'allow-same-origin allow-scripts' + }, + on: { + load: 'loaded' + } + }; + + /** + * A promise returned by {@link init} since iframe loading may be asynchronous. + * + * **Note**: Listening to `load` in {@link init} makes no sense because at this point + * the element is already in the DOM and the `load` event might already be fired. + * + * See {@link _iframeDeferred}. + * + * @private + * @member {Object} ui.iframe.IframeView#_iframePromise + */ + this._iframePromise = new Promise( ( resolve, reject ) => { + /** + * A deferred object used to resolve the iframe promise associated with + * asynchronous loading of `contentDocument`. See {@link _iframePromise}. + * + * @private + * @member {Object} ui.iframe.IframeView#_iframeDefrred + */ + this._iframeDeferred = { resolve, reject }; + } ); + + this.on( 'loaded', () => { + this._iframeDeferred.resolve(); + } ); + } + + /** + * Initializes iframe {@link element} and returns a `Promise` for asynchronous + * child `contentDocument` loading process. See {@link _iframePromise}. + * + * @returns {Promise} A promise which resolves once the iframe `contentDocument` has + * been {@link ui.iframe.IframeView#loaded loaded}. + */ + init() { + super.init(); + + return this._iframePromise; + } +} + +/** + * Fired when the iframe `contentDocument` finished loading. + * + * @event ui.iframe.IframeView#loaded + */ diff --git a/src/stickytoolbar/stickytoolbarview.js b/src/stickytoolbar/stickytoolbarview.js new file mode 100644 index 0000000..2f3fba6 --- /dev/null +++ b/src/stickytoolbar/stickytoolbarview.js @@ -0,0 +1,156 @@ +/** + * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +'use strict'; + +import ToolbarView from '../toolbar/toolbarview.js'; + +/** + * The sticky toolbar view class. + * + * @memberOf ui.stickyToolbar + * @extends ui.toolbar.ToolbarView + */ +export default class StickyToolbarView extends ToolbarView { + constructor( model ) { + super( model ); + + const bind = this.attributeBinder; + + this.model.set( 'isSticky', false ); + + // Toggle class of the toolbar when "sticky" state changes in the model. + this.template.attributes.class.push( bind.if( 'isSticky', 'ck-toolbar_sticky' ) ); + } + + init() { + super.init(); + + /** + * A dummy element which visually fills the space as long as the + * actual toolbar is sticky. It prevents flickering of the UI. + * + * @private + * @property {HTMLElement} ui.stickyToobar.StickyToolbarView#_elementPlaceholder + */ + this._elementPlaceholder = document.createElement( 'div' ); + this._elementPlaceholder.classList.add( 'ck-toolbar__placeholder' ); + this.element.parentNode.insertBefore( this._elementPlaceholder, this.element ); + + // Update sticky state of the toolbar as the window is being scrolled. + this.listenTo( window, 'scroll', () => { + this._checkIfShouldBeSticky(); + } ); + + // Synchronize with `model.isActive` because sticking an inactive toolbar is pointless. + this.listenTo( this.model, 'change:isActive', ( evt, name, value ) => { + if ( value ) { + this._checkIfShouldBeSticky(); + } else { + this._detach(); + } + } ); + } + + /** + * Destroys the toolbar and removes the {@link _elementPlaceholder}. + */ + destroy() { + super.destroy(); + + this._elementPlaceholder.remove(); + } + + /** + * Analyzes the environment to decide whether the toolbar should + * be sticky or not. Then, it uses {@link _stick} and {@link _detach} + * methods to manage the state of the toolbar. + * + * @protected + */ + _checkIfShouldBeSticky() { + const rectElement = this.model.isSticky ? + this._elementPlaceholder : this.element; + const rect = rectElement.getBoundingClientRect(); + + if ( rect.top < 0 && this.model.isActive ) { + this._stick( rect ); + } else { + this._detach(); + } + } + + /** + * Sticks the toolbar to the top edge of the viewport simulating + * CSS position:sticky. Also see {@link #_detach}. + * + * TODO: Possibly replaced by CSS in the future + * http://caniuse.com/#feat=css-sticky + * + * @protected + * @param {Object} regionRect An output of getBoundingClientRect native DOM method. + */ + _stick( regionRect ) { + // Setup placeholder. + Object.assign( this._elementPlaceholder.style, { + display: 'block', + height: regionRect.height + 'px' + } ); + + // Stick the top region. + Object.assign( this.element.style, { + // Compensate 1px border which is added when becoming "sticky". + width: regionRect.width + 2 + 'px', + marginLeft: -window.scrollX - 1 + 'px' + } ); + + this.model.isSticky = true; + } + + /** + * Detaches the toolbar from the top edge of the viewport. + * See {@link #_stick}. + * + * @protected + */ + _detach() { + // Release the placeholder. + Object.assign( this._elementPlaceholder.style, { + display: 'none' + } ); + + // Detach the top region. + Object.assign( this.element.style, { + width: 'auto', + marginLeft: 'auto' + } ); + + this.model.isSticky = false; + } +} + +/** + * The basic sticky toolbar model interface. + * + * @memberOf ui.stickyToolbar + * @interface StickyToolbarModel + */ + +/** + * Indicates whether the toolbar should be active. When any editable + * is focused in the editor, toolbar becomes active. + * + * @readonly + * @observable + * @member {Boolean} ui.button.StickyToolbarModel#isActive + */ + +/** + * Indicates whether the toolbar is in the "sticky" state. + * + * @readonly + * @observable + * @member {Boolean} ui.button.StickyToolbarModel#isSticky + */ diff --git a/src/toolbar/toolbarview.js b/src/toolbar/toolbarview.js index 8300515..f9ff3e9 100644 --- a/src/toolbar/toolbarview.js +++ b/src/toolbar/toolbarview.js @@ -21,7 +21,7 @@ export default class ToolbarView extends View { this.template = { tag: 'div', attributes: { - class: [ 'ck-toolbar' ] + class: [ 'ck-reset ck-toolbar' ] } }; diff --git a/tests/iframe/iframeview.js b/tests/iframe/iframeview.js new file mode 100644 index 0000000..279fe36 --- /dev/null +++ b/tests/iframe/iframeview.js @@ -0,0 +1,69 @@ +/** + * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* bender-tags: ui, iframe */ + +'use strict'; + +import IframeView from '/ckeditor5/ui/iframe/iframeview.js'; +import Model from '/ckeditor5/ui/model.js'; + +describe( 'IframeView', () => { + let model, view; + + beforeEach( () => { + model = new Model( { + width: 100, + height: 200 + } ); + + view = new IframeView( model ); + + view.init(); + } ); + + describe( 'constructor', () => { + it( 'creates view element from the template', () => { + expect( view.element.classList.contains( 'ck-reset-all' ) ).to.be.true; + expect( view.element.attributes.getNamedItem( 'sandbox' ).value ).to.equal( 'allow-same-origin allow-scripts' ); + } ); + } ); + + describe( 'init', () => { + it( 'returns promise', () => { + view = new IframeView( model ); + + expect( view.init() ).to.be.an.instanceof( Promise ); + } ); + + it( 'returns promise which is resolved when iframe finished loading', () => { + view = new IframeView( model ); + + const promise = view.init().then( () => { + expect( view.element.contentDocument.readyState ).to.equal( 'complete' ); + } ); + + // Moving iframe into DOM trigger creation of a document inside iframe. + document.body.appendChild( view.element ); + + return promise; + } ); + } ); + + describe( 'loaded event', () => { + it( 'is fired when frame finished loading', ( done ) => { + view = new IframeView( model ); + + view.on( 'loaded', () => { + done(); + } ); + + view.init(); + + // Moving iframe into DOM trigger creation of a document inside iframe. + document.body.appendChild( view.element ); + } ); + } ); +} ); diff --git a/tests/stickytoolbar/stickytoolbarview.js b/tests/stickytoolbar/stickytoolbarview.js new file mode 100644 index 0000000..d59e8e1 --- /dev/null +++ b/tests/stickytoolbar/stickytoolbarview.js @@ -0,0 +1,203 @@ +/** + * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* bender-tags: ui, toolbar */ + +'use strict'; + +import testUtils from '/tests/ckeditor5/_utils/utils.js'; +import StickyToolbarView from '/ckeditor5/ui/stickytoolbar/stickytoolbarview.js'; +import ToolbarView from '/ckeditor5/ui/toolbar/toolbarview.js'; +import Model from '/ckeditor5/ui/model.js'; + +testUtils.createSinonSandbox(); + +describe( 'StickyToolbarView', () => { + let model, view; + + beforeEach( () => { + model = new Model(); + view = new StickyToolbarView( model ); + + // In real life, it would be set by the Toollbar Controller (like editor binding). + model.set( 'isActive', false ); + + document.body.appendChild( view.element ); + + return view.init(); + } ); + + describe( 'constructor', () => { + it( 'inherits from ToolbarView', () => { + expect( view ).to.be.instanceof( ToolbarView ); + } ); + + it( 'sets model.isSticky false', () => { + expect( model.isSticky ).to.be.false; + } ); + } ); + + describe( 'view.element bindings', () => { + it( 'work when model.isSticky changes', () => { + expect( view.element.classList.contains( 'ck-toolbar_sticky' ) ).to.be.false; + + model.isSticky = true; + + expect( view.element.classList.contains( 'ck-toolbar_sticky' ) ).to.be.true; + } ); + } ); + + describe( 'init', () => { + it( 'creates view._elementPlaceholder', () => { + expect( view._elementPlaceholder.classList.contains( 'ck-toolbar__placeholder' ) ).to.be.true; + expect( view.element.previousSibling ).to.equal( view._elementPlaceholder ); + } ); + + it( 'listens to window#scroll event and calls view._checkIfShouldBeSticky', () => { + const spy = testUtils.sinon.spy( view, '_checkIfShouldBeSticky' ); + + window.dispatchEvent( new Event( 'scroll' ) ); + + expect( spy.calledOnce ).to.be.true; + } ); + + it( 'listens to model.isActive calls view._checkIfShouldBeSticky or view.detach', () => { + const checkSpy = testUtils.sinon.spy( view, '_checkIfShouldBeSticky' ); + const detachSpy = testUtils.sinon.spy( view, '_detach' ); + + expect( checkSpy.notCalled ).to.be.true; + expect( detachSpy.notCalled ).to.be.true; + + model.isActive = true; + + expect( checkSpy.calledOnce ).to.be.true; + expect( detachSpy.calledOnce ).to.be.true; + + model.isActive = false; + + expect( checkSpy.calledOnce ).to.be.true; + expect( detachSpy.calledTwice ).to.be.true; + } ); + } ); + + describe( 'destroy', () => { + it( 'calls destroy on parent class', () => { + const spy = testUtils.sinon.spy( ToolbarView.prototype, 'destroy' ); + + view.destroy(); + + expect( spy.calledOnce ).to.be.true; + } ); + + it( 'removes view._elementPlaceholder from DOM', () => { + view.destroy(); + expect( view._elementPlaceholder.parentNode ).to.be.null; + } ); + } ); + + describe( '_checkIfShouldBeSticky', () => { + it( 'sticks the toolbar if beyond the top of the viewport (toolbar is active)', () => { + model.isActive = true; + + testUtils.sinon.stub( HTMLElement.prototype, 'getBoundingClientRect', () => { + return { + top: -10 + }; + } ); + + const stickSpy = testUtils.sinon.spy( view, '_stick' ); + const detachSpy = testUtils.sinon.spy( view, '_detach' ); + + view._checkIfShouldBeSticky(); + + expect( stickSpy.calledOnce ).to.be.true; + expect( detachSpy.notCalled ).to.be.true; + } ); + + it( 'detaches the toolbar if beyond the top of the viewport (toolbar is inactive)', () => { + model.isActive = false; + + testUtils.sinon.stub( HTMLElement.prototype, 'getBoundingClientRect', () => { + return { + top: -10 + }; + } ); + + const stickSpy = testUtils.sinon.spy( view, '_stick' ); + const detachSpy = testUtils.sinon.spy( view, '_detach' ); + + view._checkIfShouldBeSticky(); + + expect( stickSpy.notCalled ).to.be.true; + expect( detachSpy.calledOnce ).to.be.true; + } ); + + it( 'detaches the toolbar if in the viewport (toolbar is active)', () => { + model.isActive = true; + + testUtils.sinon.stub( HTMLElement.prototype, 'getBoundingClientRect', () => { + return { + top: 10 + }; + } ); + + const stickSpy = testUtils.sinon.spy( view, '_stick' ); + const detachSpy = testUtils.sinon.spy( view, '_detach' ); + + view._checkIfShouldBeSticky(); + + expect( stickSpy.notCalled ).to.be.true; + expect( detachSpy.calledOnce ).to.be.true; + } ); + } ); + + describe( '_stick', () => { + it( 'updates view._elementPlaceholder styles', () => { + view._stick( { top: 10, width: 20, height: 30 } ); + + expect( view._elementPlaceholder.style.display ).to.equal( 'block' ); + expect( view._elementPlaceholder.style.height ).to.equal( '30px' ); + } ); + + it( 'updates view.element styles', () => { + view._stick( { top: 10, width: 20, height: 30 } ); + + expect( view.element.style.width ).to.equal( '22px' ); + // It's tricky to mock window.scrollX. + expect( view.element.style.marginLeft ).to.not.equal( '' ); + } ); + + it( 'updates model.isSticky attribute', () => { + expect( model.isSticky ).to.be.false; + + view._stick( { top: 10, width: 20, height: 30 } ); + + expect( model.isSticky ).to.be.true; + } ); + } ); + + describe( '_detach', () => { + it( 'updates view._elementPlaceholder styles', () => { + view._detach(); + + expect( view._elementPlaceholder.style.display ).to.equal( 'none' ); + } ); + + it( 'updates view.element styles', () => { + view._detach(); + + expect( view.element.style.width ).to.equal( 'auto' ); + expect( view.element.style.marginLeft ).to.equal( 'auto' ); + } ); + + it( 'updates model.isSticky attribute', () => { + model.isSticky = true; + + view._detach(); + + expect( model.isSticky ).to.be.false; + } ); + } ); +} ); diff --git a/theme/components/stickytoolbar.scss b/theme/components/stickytoolbar.scss new file mode 100644 index 0000000..725e9ca --- /dev/null +++ b/theme/components/stickytoolbar.scss @@ -0,0 +1,11 @@ +// Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. +// For licensing, see LICENSE.md or http://ckeditor.com/license + +@include ck-editor { + .ck-toolbar.ck-toolbar_sticky { + position: fixed; + top: 0; + border: 1px solid ck-border-color(); + background: ck-color( 'foreground' ); + } +} diff --git a/theme/theme.scss b/theme/theme.scss new file mode 100644 index 0000000..dcddcb8 --- /dev/null +++ b/theme/theme.scss @@ -0,0 +1,4 @@ +// Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. +// For licensing, see LICENSE.md or http://ckeditor.com/license + +@import 'components/stickytoolbar';