Skip to content
This repository has been archived by the owner on Jun 26, 2020. It is now read-only.

Commit

Permalink
Merge pull request #872 from ckeditor/t/857
Browse files Browse the repository at this point in the history
Feature: Added placeholder utility that can be applied to view elements. Closes #857.
  • Loading branch information
Piotr Jasiun authored Mar 22, 2017
2 parents 115a91b + 010fc06 commit 79b42da
Show file tree
Hide file tree
Showing 6 changed files with 345 additions and 0 deletions.
126 changes: 126 additions & 0 deletions src/view/placeholder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md.
*/

/**
* @module engine/view/placeholder
*/

import extend from '@ckeditor/ckeditor5-utils/src/lib/lodash/extend';
import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
import '../../theme/placeholder.scss';

const listener = {};
extend( listener, EmitterMixin );

// Each document stores information about its placeholder elements and check functions.
const documentPlaceholders = new WeakMap();

/**
* Attaches placeholder to provided element and updates it's visibility. To change placeholder simply call this method
* once again with new parameters.
*
* @param {module:engine/view/element~Element} element Element to attach placeholder to.
* @param {String} placeholderText Placeholder text to use.
* @param {Function} [checkFunction] If provided it will be called before checking if placeholder should be displayed.
* If function returns `false` placeholder will not be showed.
*/
export function attachPlaceholder( element, placeholderText, checkFunction ) {
const document = element.document;

if ( !document ) {
/**
* Provided element is not placed in any {@link module:engine/view/document~Document}.
*
* @error view-placeholder-element-is-detached
*/
throw new CKEditorError( 'view-placeholder-element-is-detached: Provided element is not placed in document.' );
}

// Detach placeholder if was used before.
detachPlaceholder( element );

// Single listener per document.
if ( !documentPlaceholders.has( document ) ) {
documentPlaceholders.set( document, new Map() );
listener.listenTo( document, 'render', () => updateAllPlaceholders( document ), { priority: 'high' } );
}

// Store text in element's data attribute.
// This data attribute is used in CSS class to show the placeholder.
element.setAttribute( 'data-placeholder', placeholderText );

// Store information about placeholder.
documentPlaceholders.get( document ).set( element, checkFunction );

// Update right away too.
updateSinglePlaceholder( element, checkFunction );
}

/**
* Removes placeholder functionality from given element.
*
* @param {module:engine/view/element~Element} element
*/
export function detachPlaceholder( element ) {
const document = element.document;

element.removeClass( 'ck-placeholder' );
element.removeAttribute( 'data-placeholder' );

if ( documentPlaceholders.has( document ) ) {
documentPlaceholders.get( document ).delete( element );
}
}

// Updates all placeholders of given document.
//
// @private
// @param {module:engine/view/document~Document} document
function updateAllPlaceholders( document ) {
const placeholders = documentPlaceholders.get( document );

for ( let [ element, checkFunction ] of placeholders ) {
updateSinglePlaceholder( element, checkFunction );
}
}

// Updates placeholder class of given element.
//
// @private
// @param {module:engine/view/element~Element} element
// @param {Function} checkFunction
function updateSinglePlaceholder( element, checkFunction ) {
const document = element.document;

// Element was removed from document.
if ( !document ) {
return;
}

const viewSelection = document.selection;
const anchor = viewSelection.anchor;

// If checkFunction is provided and returns false - remove placeholder.
if ( checkFunction && !checkFunction() ) {
element.removeClass( 'ck-placeholder' );

return;
}

// If element is empty and editor is blurred.
if ( !document.isFocused && !element.childCount ) {
element.addClass( 'ck-placeholder' );

return;
}

// It there are no child elements and selection is not placed inside element.
if ( !element.childCount && anchor && anchor.parent !== element ) {
element.addClass( 'ck-placeholder' );
} else {
element.removeClass( 'ck-placeholder' );
}
}
1 change: 1 addition & 0 deletions tests/manual/placeholder.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div id="editor"><h2></h2><p></p></div>
32 changes: 32 additions & 0 deletions tests/manual/placeholder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md.
*/

/* global console */

import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classic';
import Enter from '@ckeditor/ckeditor5-enter/src/enter';
import Typing from '@ckeditor/ckeditor5-typing/src/typing';
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
import Undo from '@ckeditor/ckeditor5-undo/src/undo';
import Heading from '@ckeditor/ckeditor5-heading/src/heading';
import global from '@ckeditor/ckeditor5-utils/src/dom/global';
import { attachPlaceholder } from '../../src/view/placeholder';

ClassicEditor.create( global.document.querySelector( '#editor' ), {
plugins: [ Enter, Typing, Paragraph, Undo, Heading ],
toolbar: [ 'headings', 'undo', 'redo' ]
} )
.then( editor => {
const viewDoc = editor.editing.view;
const header = viewDoc.getRoot().getChild( 0 );
const paragraph = viewDoc.getRoot().getChild( 1 );

attachPlaceholder( header, 'Type some header text...' );
attachPlaceholder( paragraph, 'Type some paragraph text...' );
viewDoc.render();
} )
.catch( err => {
console.error( err.stack );
} );
9 changes: 9 additions & 0 deletions tests/manual/placeholder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### Placeholder creation

* You should see two placeholders:
* for heading: `Type some header text...`,
* and for paragraph: `Type some paragraph text...`.
* Clicking on header and paragraph should remove placeholder.
* Clicking outside the editor should show both placeholders.
* Type some text into paragraph, and click outside. Paragraph placeholder should be hidden.
* Remove added text and click outside - paragraph placeholder should now be visible again.
169 changes: 169 additions & 0 deletions tests/view/placeholder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/**
* @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md.
*/

import { attachPlaceholder, detachPlaceholder } from '../../src/view/placeholder';
import ViewContainerElement from '../../src/view/containerelement';
import ViewDocument from '../../src/view/document';
import ViewRange from '../../src/view/range';
import { setData } from '../../src/dev-utils/view';

describe( 'placeholder', () => {
let viewDocument, viewRoot;

beforeEach( () => {
viewDocument = new ViewDocument();
viewRoot = viewDocument.createRoot( 'main' );
viewDocument.isFocused = true;
} );

describe( 'createPlaceholder', () => {
it( 'should throw if element is not inside document', () => {
const element = new ViewContainerElement( 'div' );

expect( () => {
attachPlaceholder( element, 'foo bar baz' );
} ).to.throw( 'view-placeholder-element-is-detached' );
} );

it( 'should attach proper CSS class and data attribute', () => {
setData( viewDocument, '<div></div><div>{another div}</div>' );
const element = viewRoot.getChild( 0 );

attachPlaceholder( element, 'foo bar baz' );

expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
} );

it( 'if element has children set only data attribute', () => {
setData( viewDocument, '<div>first div</div><div>{another div}</div>' );
const element = viewRoot.getChild( 0 );

attachPlaceholder( element, 'foo bar baz' );

expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
} );

it( 'if element has selection inside set only data attribute', () => {
setData( viewDocument, '<div>[]</div><div>another div</div>' );
const element = viewRoot.getChild( 0 );

attachPlaceholder( element, 'foo bar baz' );

expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
} );

it( 'if element has selection inside but document is blurred should contain placeholder CSS class', () => {
setData( viewDocument, '<div>[]</div><div>another div</div>' );
const element = viewRoot.getChild( 0 );
viewDocument.isFocused = false;

attachPlaceholder( element, 'foo bar baz' );

expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
} );

it( 'use check function if one is provided', () => {
setData( viewDocument, '<div></div><div>{another div}</div>' );
const element = viewRoot.getChild( 0 );
const spy = sinon.spy( () => false );

attachPlaceholder( element, 'foo bar baz', spy );

sinon.assert.calledOnce( spy );
expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
} );

it( 'should remove CSS class if selection is moved inside', () => {
setData( viewDocument, '<div></div><div>{another div}</div>' );
const element = viewRoot.getChild( 0 );

attachPlaceholder( element, 'foo bar baz' );

expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;

viewDocument.selection.setRanges( [ ViewRange.createIn( element ) ] );
viewDocument.render();

expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
} );

it( 'should change placeholder settings when called twice', () => {
setData( viewDocument, '<div></div><div>{another div}</div>' );
const element = viewRoot.getChild( 0 );

attachPlaceholder( element, 'foo bar baz' );
attachPlaceholder( element, 'new text' );

expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'new text' );
expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
} );

it( 'should not throw when element is no longer in document', () => {
setData( viewDocument, '<div></div><div>{another div}</div>' );
const element = viewRoot.getChild( 0 );

attachPlaceholder( element, 'foo bar baz' );
setData( viewDocument, '<p>paragraph</p>' );

viewDocument.render();
} );

it( 'should allow to add placeholder to elements from different documents', () => {
setData( viewDocument, '<div></div><div>{another div}</div>' );
const element = viewRoot.getChild( 0 );
const secondDocument = new ViewDocument();
secondDocument.isFocused = true;
const secondRoot = secondDocument.createRoot( 'main' );
setData( secondDocument, '<div></div><div>{another div}</div>' );
const secondElement = secondRoot.getChild( 0 );

attachPlaceholder( element, 'first placeholder' );
attachPlaceholder( secondElement, 'second placeholder' );

expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'first placeholder' );
expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;

expect( secondElement.getAttribute( 'data-placeholder' ) ).to.equal( 'second placeholder' );
expect( secondElement.hasClass( 'ck-placeholder' ) ).to.be.true;

// Move selection to the elements with placeholders.
viewDocument.selection.setRanges( [ ViewRange.createIn( element ) ] );
secondDocument.selection.setRanges( [ ViewRange.createIn( secondElement ) ] );

// Render changes.
viewDocument.render();
secondDocument.render();

expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'first placeholder' );
expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;

expect( secondElement.getAttribute( 'data-placeholder' ) ).to.equal( 'second placeholder' );
expect( secondElement.hasClass( 'ck-placeholder' ) ).to.be.false;
} );
} );

describe( 'detachPlaceholder', () => {
it( 'should remove placeholder from element', () => {
setData( viewDocument, '<div></div><div>{another div}</div>' );
const element = viewRoot.getChild( 0 );

attachPlaceholder( element, 'foo bar baz' );

expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;

detachPlaceholder( element );

expect( element.hasAttribute( 'data-placeholder' ) ).to.be.false;
expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
} );
} );
} );
8 changes: 8 additions & 0 deletions theme/placeholder.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
// For licensing, see LICENSE.md or http://ckeditor.com/license

.ck-placeholder::before {
content: attr( data-placeholder );
cursor: text;
color: #c2c2c2;
}

0 comments on commit 79b42da

Please sign in to comment.