Instantly share code, notes, and snippets.
Last active
February 28, 2022 13:15
-
Star
(1)
1
You must be signed in to star a gist -
Fork
(0)
0
You must be signed in to fork a gist
-
Save iiic/f279174ef577da0ecb513c9b5fc3861d to your computer and use it in GitHub Desktop.
Create tags using contenteditable object replacing textarea with class "tags"
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!doctype html> | |
<html> | |
<head> | |
<meta charset="utf-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1"> | |
<title>Tagy předělávkou textareji na element s atributem contenteditable</title> | |
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet"> | |
<style> | |
@charset "UTF-8"; | |
body { | |
font-family: 'Roboto', sans-serif; | |
} | |
.tags>span { | |
/* do NOT use position:relative in here */ | |
display: inline-block; | |
background: #e0dee0; | |
font-size: 1.1em; | |
border-radius: 1.1em; | |
padding: 0.36em 1.8em 0.34em 0.5em; | |
margin-left: 0.5em; | |
margin-right: 0.1em; | |
margin-bottom: 0.2em; | |
} | |
.tags>span::after { | |
font-family: "Font Awesome 5 Free", FontAwesome; | |
content: '\f057'; | |
font-size: 24px; | |
/* FA Free - times (\f00d) works only with font-weight: 900 */ | |
width: 22px; | |
height: 22px; | |
background: #979797; | |
background: radial-gradient(circle, #979797 calc(22px / 2), transparent 0) left 2px top 2px; | |
background-repeat: no-repeat; | |
color: #e0dee0; | |
border-radius: 50%; | |
text-align: center; | |
position: absolute; | |
display: inline-block; | |
margin-left: 0.1em; | |
margin-top: -0.07em; | |
cursor: pointer; | |
} | |
.tags>span:hover { | |
color: #fff; | |
background: #767577; | |
} | |
.tags>span:hover::after { | |
background: #fff; | |
background: radial-gradient(circle, #fff calc(22px / 2), transparent 0) left 2px top 2px; | |
background-repeat: no-repeat; | |
color: #767577; | |
} | |
.tags>span.closing-hover::after { | |
color: black; | |
} | |
div[contenteditable] { | |
border: 1px inset #aaa; | |
padding: 0.8em 0.8em 0.8em 0.3em; | |
min-height: 4em; | |
border-radius: 0.4em; | |
cursor: text; | |
caret-color: blue; | |
} | |
</style> | |
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.12/css/all.css" integrity="sha384-G0fIWCsCzJIMAVNQPfjH08cyYaUtMwjJwqiRKxxE/rx96Uroj1BtIQ6MLJuheaO9" | |
crossorigin="anonymous"> | |
</head> | |
<body> | |
<textarea class="tags">první, druhýTag, třetíTag, a, tak</textarea> | |
<hr> | |
<textarea class="tags" placeholder="simulovaný (pokud se vše povedlo správně) placeholder"></textarea> | |
<hr> | |
<label> | |
<textarea class="tags">první, druhýTag, třetíTag, a, tak</textarea> | |
</label> | |
<hr> | |
<label for="prvni"> | |
popisek a: | |
<textarea id="prvni" class="tags">první, druhýTag, třetíTag, a, tak</textarea> | |
</label> | |
<hr> | |
<label for="druhy"> | |
<p>popisek b:</p> | |
<div> | |
<textarea id="druhy" class="tags">první, druhýTag, třetíTag, a, tak</textarea> | |
</div> | |
</label> | |
<hr> | |
<script title="tagsBuilder.defer.js"> | |
// can be defer | |
'use strict'; | |
( () => { | |
const PLACEHOLDER_CLASS_NAME = 'placeholder'; | |
const FAKE_TRUSTED_DETAIL_STRING = 'fakeTrusted'; | |
const TEXTAREA_SEPARATOR = ','; | |
const INPUT_EVENT_NAME = 'input'; | |
const DIVIDER_OPTIONS_REGEXP = new RegExp( '(?:,|;|\\s| |\\r?\\n)+', 'u' ); // many possible dividers | |
const NO_BREAK_SPACE = '\u00A0'; | |
// originální textarea se chová tak, že pozice kurzoru uvnitř je zapamatovaná a při přecházení tab-em mezi jednotlivými textarejemi se kurzor umisťuje na poslední známé umístění kurzoru v té které textareji | |
// klávesy end a home by se měly opravit… end vždy posouvá na konec aktuálního tagu, ale pokud už kurzor na konci je, tak end by měl skočit na konec rodiče | |
// při kliku na label (do labelu) focusovat contenteditable | |
// při plnění prázného conteneditable bez focusu se nesmaže placeholder | |
// FF má specifické chování, jde kurzorem doscrollovat na první pozici v tagu (v Chromu to nejde, i když chování FF je logičtější) | |
/** | |
* @todo : description | |
* @returns {HTMLElement} | |
*/ | |
function createFakePlaceholder( placeholderText = '', placeHolderClassName = '' ) { | |
const fakePlaceholder = document.createElement( 'small' ); | |
fakePlaceholder.classList.add( placeHolderClassName ); | |
fakePlaceholder.appendChild( document.createTextNode( placeholderText ) ); | |
return fakePlaceholder; | |
} | |
/** | |
* @todo : description | |
* @returns {true} | |
*/ | |
function setCaretAtTheEndOf( /** @type {HTMLElement} and it's descendants */ element ) { | |
const range = document.createRange(); | |
const sel = window.getSelection(); | |
if ( element.textContent.slice( -TEXTAREA_SEPARATOR.length ) === TEXTAREA_SEPARATOR ) | |
{ | |
range.setStart( element.lastChild, TEXTAREA_SEPARATOR.length ); | |
} else if ( element.lastElementChild ) | |
{ | |
range.setStart( element.lastElementChild, 1 ); | |
} | |
range.collapse( true ); | |
sel.removeAllRanges(); | |
sel.addRange( range ); | |
return true; | |
} | |
/** | |
* @todo : description | |
* @returns {true} | |
*/ | |
function moveCaretBy( /** @type {Number} positive or negative */ charCount = 1 ) { | |
/** @type {Selection} */ | |
let selection; | |
if ( window.getSelection ) | |
{ | |
selection = window.getSelection(); | |
if ( selection.rangeCount > 0 ) | |
{ | |
/** @type {HTMLDivElement} */ | |
const focusNode = ( selection.focusNode ); // HTMLDivElement in FF, Text in Chrome | |
if ( focusNode.constructor.name === 'HTMLDivElement' ) | |
{ // FF specific | |
setCaretAtTheEndOf( focusNode ); | |
return true; | |
} | |
const position = Math.min( focusNode.textContent.length, selection.focusOffset + charCount ); | |
//console.log( position ); | |
if ( !Number.isInteger( position ) ) | |
{ | |
return true; | |
} | |
if ( position < 0 ) | |
{ | |
//console.log( 'a' ); | |
return true; | |
} else if ( position === 0 ) | |
{ | |
//console.log( 'b' ); | |
if ( focusNode.previousSibling ) | |
{ | |
//console.log( 'c' ); | |
selection.collapse( focusNode.previousSibling, 1 ); | |
} else | |
{ | |
selection.collapse( focusNode.parentNode.previousSibling, 1 ); | |
//console.log( 'd' ); | |
} | |
} else if ( position === 1 ) | |
{ | |
//console.log( 'e' ); | |
selection.collapse( focusNode.nextSibling, 1 ); | |
} else | |
{ | |
//console.log( 'f' ); | |
selection.collapse( focusNode, position ); | |
} | |
} | |
} else if ( ( selection = window.document[ 'selection' ] ) ) | |
{ // old IE's | |
if ( selection.type !== 'Control' ) | |
{ | |
/** @type {any} */ | |
const ieSel = selection; | |
const range = ieSel.createRange(); | |
range.move( 'character', charCount ); | |
range.select(); | |
} | |
} | |
return true; | |
} | |
/** | |
* @todo : description | |
* @todo : in future returns HTMLSpanElement or input | |
* @returns {HTMLSpanElement} | |
*/ | |
function createSingleTag( textContent = '' ) { | |
const USE_SUGGESTIONS = false; // @todo : create suggestions by using input[list="<id>"] and shared datalist[id="<id>"] | |
const CLOSER_DIMENSIONS = [ 30, 23 ]; // [start, width] in px | |
const CLOSING_HOVER_CLASS_NAME = 'closing-hover'; | |
const tag = document.createElement( USE_SUGGESTIONS ? 'input' : 'span' ); // @todo | |
if ( 'value' in tag ) | |
{ | |
tag.value = textContent; | |
} else | |
{ | |
tag.appendChild( document.createTextNode( NO_BREAK_SPACE + textContent ) ); | |
} | |
tag.onmouseout = ( /** @type {MouseEvent} */ event ) => { | |
/** @type {HTMLSpanElement} */ | |
const eventTarget = ( event.target ); | |
eventTarget.classList.remove( CLOSING_HOVER_CLASS_NAME ); | |
}; | |
tag.onmousemove = ( /** @type {MouseEvent} */ event ) => { | |
/** @type {HTMLSpanElement} */ | |
const eventTarget = ( event.target ); | |
if ( event.pageX + CLOSER_DIMENSIONS[ 0 ] > eventTarget.offsetLeft + eventTarget.offsetWidth ) | |
{ | |
if ( event.pageX + CLOSER_DIMENSIONS[ 0 ] > eventTarget.offsetLeft + eventTarget.offsetWidth + CLOSER_DIMENSIONS[ 1 ] ) | |
{ | |
eventTarget.classList.remove( CLOSING_HOVER_CLASS_NAME ); | |
} else | |
{ | |
eventTarget.classList.add( CLOSING_HOVER_CLASS_NAME ); | |
} | |
} else | |
{ | |
eventTarget.classList.remove( CLOSING_HOVER_CLASS_NAME ); | |
} | |
}; | |
tag.onclick = ( /** @type {MouseEvent} */ event ) => { // tag self-destruction by click | |
/** @type {HTMLSpanElement} */ | |
const eventTarget = ( event.target ); | |
if ( | |
( eventTarget.offsetLeft + eventTarget.offsetWidth ) < ( event.pageX + CLOSER_DIMENSIONS[ 0 ] ) | |
&& ( event.pageX + CLOSER_DIMENSIONS[ 0 ] < eventTarget.offsetLeft + eventTarget.offsetWidth + CLOSER_DIMENSIONS[ 1 ] ) | |
) | |
{ // if clicked on ::after pseudo element content | |
/** @type {HTMLDivElement} */ | |
const tagsRoot = ( eventTarget.parentNode ); | |
const nextText = eventTarget.nextSibling; | |
if ( nextText && nextText.nodeType === Node.TEXT_NODE && nextText.textContent === TEXTAREA_SEPARATOR ) | |
{ | |
tagsRoot.removeChild( nextText ); | |
} | |
tagsRoot.removeChild( eventTarget ); | |
tagsRoot.dispatchEvent( new CustomEvent( INPUT_EVENT_NAME, { 'detail': FAKE_TRUSTED_DETAIL_STRING } ) ); | |
//setCaretAtTheEndOf( tagsRoot ); | |
} | |
}; | |
return tag; | |
} | |
/** | |
* @todo : description | |
* @returns {Array} | |
*/ | |
function readFromTextarea( /** @type {HTMLTextAreaElement } */ element ) { | |
if ( element && element.nodeType === Node.ELEMENT_NODE && 'value' in element && element.value ) | |
{ | |
const regexp = DIVIDER_OPTIONS_REGEXP; | |
const list = element.value.split( regexp ); | |
const withoutEmptyItems = list.filter( String ); | |
return [ ...new Set( withoutEmptyItems ) ]; // this make [] unique | |
} | |
return []; | |
} | |
/** | |
* @todo : description | |
* @returns {Array} | |
*/ | |
function readFromEditableElement( /** @type {HTMLElement} and it's descendants */ element ) { | |
if ( element && element.nodeType === Node.ELEMENT_NODE && element.children && element.children.length ) | |
{ | |
const withoutEmptyItems = []; | |
[ ...element.children ].forEach( ( /** @type {HTMLSpanElement } */ child ) => { | |
withoutEmptyItems.push( child.textContent ); | |
} ); | |
return [ ...new Set( withoutEmptyItems ) ]; // this make [] unique | |
} | |
return []; | |
} | |
/** | |
* @todo : description | |
* @returns {true} | |
*/ | |
function removeFirstSeparatorFrom(/** @type {HTMLElement} */ tagsRoot ) { | |
if ( ( tagsRoot.firstChild ) && ( tagsRoot.firstChild.textContent === TEXTAREA_SEPARATOR ) ) | |
{ | |
tagsRoot.removeChild( tagsRoot.firstChild ); | |
} | |
return true; | |
} | |
[ ...document.getElementsByTagName( 'textarea' ) ].forEach( ( /** @type {HTMLTextAreaElement} */ textarea ) => { | |
const INITIALIZE_TEXTAREA_CLASS_NAME = 'tags'; | |
const TAGS_ROOT_CLASS_NAME = INITIALIZE_TEXTAREA_CLASS_NAME; | |
const FOCUS_NAME = 'focus'; | |
if ( textarea.classList.contains( INITIALIZE_TEXTAREA_CLASS_NAME ) ) | |
{ | |
const tagsRoot = document.createElement( 'div' ); | |
tagsRoot.contentEditable = 'true'; | |
tagsRoot.classList.add( TAGS_ROOT_CLASS_NAME ); | |
if ( textarea.placeholder ) | |
{ | |
if ( !textarea.value ) | |
{ | |
tagsRoot.appendChild( createFakePlaceholder( textarea.placeholder, PLACEHOLDER_CLASS_NAME ) ); | |
} | |
tagsRoot.addEventListener( FOCUS_NAME, ( /** @type {FocusEvent} */ event ) => { | |
/** @type {HTMLElement} - some HTMLElement (HTMLDivElement for example) with contentEditable attribute */ | |
const eventTarget = ( event.target ); | |
let rootElement = eventTarget; | |
while ( rootElement.contentEditable !== 'true' ) | |
{ // contentEditable can be strings 'true', 'false', 'inherit', and more | |
rootElement = rootElement.parentElement; | |
} | |
[ ...rootElement.children ].forEach( ( /** @type {HTMLElement} */ item ) => { | |
if ( item.classList.contains( PLACEHOLDER_CLASS_NAME ) ) | |
{ | |
setCaretAtTheEndOf( eventTarget ); | |
item.remove(); | |
} | |
} ); | |
return true; | |
}, false ); | |
tagsRoot.addEventListener( 'focusout', ( /** @type {FocusEvent} */ event ) => { | |
/** @type {HTMLElement} - some HTMLElement (HTMLDivElement for example) with contentEditable attribute */ | |
const eventTarget = ( event.target ); | |
if ( !eventTarget.textContent ) | |
{ | |
eventTarget.appendChild( createFakePlaceholder( textarea.placeholder, PLACEHOLDER_CLASS_NAME ) ); | |
} | |
}, false ); | |
} | |
tagsRoot.addEventListener( 'keypress', (/** @type {KeyboardEvent} */ event ) => { // Backspace and Delete are not in keypress, only in keydown | |
const ENTER_NAME = 'Enter'; | |
const SPACE_NAME = ' '; | |
const COMMA_NAME = ','; | |
if ( | |
event.key === ENTER_NAME || event.code === ENTER_NAME | |
|| event.key === SPACE_NAME || event.code === SPACE_NAME | |
|| event.key === COMMA_NAME || event.code === COMMA_NAME | |
) | |
{ | |
event.stopPropagation(); | |
event.preventDefault(); | |
/** @type {HTMLDivElement} */ | |
const eventTarget = ( event.target ); | |
setCaretAtTheEndOf( eventTarget ); | |
} | |
return true; | |
}, false ); | |
tagsRoot.addEventListener( 'keydown', (/** @type {KeyboardEvent} */ event ) => { // Backspace and Delete are not in keypress, only in keydown | |
const BACKSPACE_NAME = 'Backspace'; | |
const DELETE_NAME = 'Delete'; // eslint-disable-line no-unused-vars | |
/** @type {HTMLDivElement} */ | |
const eventTarget = ( event.target ); | |
[ ...eventTarget.childNodes ].forEach( (/** @type {Text | HTMLSpanElement} */ item ) => { // @ todo : těchto pár řádků dát i na focusout rodiče… když se vytabuji pryč z elementu, tak mohou zůstat 2 čárky po sobě… teoreticky i 2 elementy bez čárek (vyzkoušet) | |
if ( item.nodeType === Node.TEXT_NODE && item.nextSibling && item.nextSibling.nodeType === Node.TEXT_NODE ) | |
{ // remove multiple separators if needed | |
eventTarget.removeChild( item.nextSibling ); | |
} else if ( item.nodeType === Node.ELEMENT_NODE && item.nextSibling && item.nextSibling.nodeType === Node.ELEMENT_NODE ) | |
{ // add separator between tags if missing | |
item.parentNode.insertBefore( document.createTextNode( TEXTAREA_SEPARATOR ), item.nextSibling ); | |
} | |
} ); | |
if ( event.key === BACKSPACE_NAME || event.code === BACKSPACE_NAME ) | |
{ | |
/** @type {Selection} */ | |
const selection = window.getSelection(); | |
if ( | |
( selection.focusNode && selection.focusNode.textContent === TEXTAREA_SEPARATOR ) | |
|| selection.baseOffset === 1 | |
) | |
{ | |
/** @type {HTMLElement} - some HTMLElement (HTMLDivElement for example) with contentEditable attribute */ | |
const eventTarget = ( event.target ); | |
event.stopPropagation(); | |
event.preventDefault(); | |
moveCaretBy( -1 ); | |
[ ...eventTarget.children ].forEach( ( /** @type {HTMLSpanElement} */tag ) => { | |
if ( tag.textContent === NO_BREAK_SPACE ) | |
{ | |
if ( tag === eventTarget.firstElementChild ) | |
{ | |
setCaretAtTheEndOf( eventTarget ); // @todo : create function setCaretAtTheBeginningOf() and use it in here | |
} | |
tag.parentNode.removeChild( tag ); | |
} | |
} ); | |
} | |
} | |
removeFirstSeparatorFrom( eventTarget ); | |
return true; | |
}, false ); | |
tagsRoot.addEventListener( FOCUS_NAME, ( /** @type {FocusEvent} */ event ) => { | |
/** @type {HTMLElement} - some HTMLElement (HTMLDivElement for example) with contentEditable attribute */ | |
const eventTarget = ( event.target ); | |
if ( | |
eventTarget.lastElementChild | |
&& !eventTarget.lastElementChild.classList.contains( PLACEHOLDER_CLASS_NAME ) | |
) | |
{ | |
setCaretAtTheEndOf( eventTarget ); | |
} | |
}, false ); | |
tagsRoot.addEventListener( INPUT_EVENT_NAME, ( /** @type {Event} */ event ) => | |
{ // it can be also InputEvent (waiting for browsers to implement)… than {Event | InputEvent} | |
/** @type {HTMLDivElement} - some HTMLElement with contentEditable attribute */ | |
const eventTarget = ( event.target ); | |
/** @type {HTMLTextAreaElement} */ | |
const originalTextarea = ( tagsRoot.previousElementSibling ); | |
originalTextarea.dispatchEvent( new Event( 'change' ) ); | |
if ( event.isTrusted || event[ 'detail' ] === FAKE_TRUSTED_DETAIL_STRING ) | |
{ // click on element with contenteditable | |
[ ...eventTarget.childNodes ].forEach( ( /** @type {Text | HTMLSpanElement} */ item ) => { | |
if ( item.nodeType === Node.TEXT_NODE ) | |
{ | |
const regexp = DIVIDER_OPTIONS_REGEXP; | |
const clearedItem = item.textContent.replace( regexp, '' ); | |
if ( clearedItem ) | |
{ | |
const tag = createSingleTag( clearedItem ); | |
eventTarget.insertBefore( document.createTextNode( TEXTAREA_SEPARATOR ), item ); | |
eventTarget.replaceChild( tag, item ); | |
moveCaretBy( 1 ); | |
eventTarget.insertBefore( document.createTextNode( TEXTAREA_SEPARATOR ), tag.nextElementSibling ); | |
} | |
} | |
} ); | |
const values = readFromEditableElement( eventTarget ); | |
originalTextarea.value = values.join( TEXTAREA_SEPARATOR ).replace( new RegExp( NO_BREAK_SPACE, 'g' ), '' ); | |
removeFirstSeparatorFrom( eventTarget ); | |
if ( eventTarget.lastChild ) | |
{ | |
while ( | |
eventTarget.lastChild.nodeType === Node.TEXT_NODE | |
&& eventTarget.lastChild.previousSibling | |
&& eventTarget.lastChild.previousSibling.nodeType === Node.TEXT_NODE | |
) | |
{ | |
eventTarget.lastChild.previousSibling[ 'textNode' ] += eventTarget.lastChild[ 'textNode' ]; | |
eventTarget.removeChild( eventTarget.lastChild ); | |
} | |
if ( eventTarget.lastChild.textContent !== TEXTAREA_SEPARATOR ) | |
{ | |
eventTarget.appendChild( document.createTextNode( TEXTAREA_SEPARATOR ) ); | |
} | |
} | |
} else | |
{ // inicialise tags | |
while ( tagsRoot.lastChild && !tagsRoot.lastElementChild.classList.contains( PLACEHOLDER_CLASS_NAME ) ) | |
{ | |
tagsRoot.removeChild( tagsRoot.lastChild ); | |
} | |
readFromTextarea( originalTextarea ).forEach( ( /** @type {String} */ tagText ) => { | |
tagsRoot.appendChild( createSingleTag( tagText ) ); | |
tagsRoot.appendChild( document.createTextNode( TEXTAREA_SEPARATOR ) ); | |
} ); | |
} | |
}, false ); | |
textarea.parentNode.insertBefore( tagsRoot, textarea.nextSibling ); | |
tagsRoot.dispatchEvent( new Event( INPUT_EVENT_NAME ) ); | |
textarea.addEventListener( INPUT_EVENT_NAME, ( /** @type {Event} */ event ) => { | |
/** @type {HTMLDivElement} */ | |
const eventTarget = ( event.target ); | |
eventTarget.nextElementSibling.dispatchEvent( new Event( INPUT_EVENT_NAME ) ); | |
}, false ); | |
textarea.hidden = true; | |
} | |
} ); | |
} )(); | |
</script> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment