import {t} from './core/translates.js'; import { el } from "./core/el.js"; interface WCWYSIWYGTag { tag:string method?:Function, hint?:string, is?: string, } interface WCWYSIWYGActions { wrapTag: Function, insertImageBlock: Function, insertAudio: Function, insertVideo: Function, } //All semantic html5 known editor tags const allTags = [ { tag: 'h1' }, { tag: 'h2' }, { tag: 'h3' }, { tag: 'h4' }, { tag: 'h5' }, { tag: 'h6' }, { tag: 'span' }, { tag: 'mark' }, { tag: 'small' }, { tag: 'dfn' }, { tag: 'a'}, { tag: 'q'}, { tag: 'b'}, { tag: 'i'}, { tag: 'u'}, { tag: 's'}, { tag: 'sup'}, { tag: 'sub'}, { tag: 'kbd'}, { tag: 'abbr'}, { tag: 'strong'}, { tag: 'code'}, { tag: 'samp'}, { tag: 'del'}, { tag: 'ins'}, { tag: 'var'}, { tag: 'ul'}, { tag: 'ol'}, { tag: 'pre'}, { tag: 'time'}, { tag: 'img'}, { tag: 'audio'}, { tag: 'video'}, { tag: 'blockquote'}, ] as WCWYSIWYGTag[]; class WCWYSIWYG extends HTMLElement { public EditorTags:WCWYSIWYGTag[] public EditorCustomTags:WCWYSIWYGTag[] //Content editable wc-editor element public EditorNode:HTMLElement public EditorActionsSection:HTMLElement //Inline edites public EditorInlineActions:any[] public EditorInlineDialog:HTMLDialogElement public EditorInlineActionsForm:HTMLElement //Editor props public EditorPropertyForm?:HTMLElement //Clear btn public EditorClearFormatBtn:HTMLElement //Autocomplete area public EditorAutoCompleteForm?:HTMLElement //Bottom actions public EditorBottomForm?:HTMLElement public EditorBottomFormNewP?:HTMLElement public EditorBottomFormViewToggle?:HTMLElement public EditorPreviewText:HTMLTextAreaElement public EditorCustomTagsForm?:HTMLElement public EditorTagsMethods:WCWYSIWYGActions public EditorAllowTags:string[] public EditorFullScreenButton?:HTMLElement public lang:string = 'ru' public value:string = '' static observedAttributes = ['value']; #EditProps:boolean|object #Autocomplete:boolean #SotrageKey:string|null #HideBottomActions:boolean #Connected:boolean = false; constructor() { super(); this.classList.add('wc-wysiwyg'); //Listen root element events this.onpointerup = (event) => { const selection = window.getSelection(); //if check exist selection string if(selection !== null && selection.toString().length > 0) { this.EditorInlineActionsForm.style.display = ''; this.EditorPropertyForm.style.display = 'none'; this.showEditorInlineDialog(); } else { this.hideEditorInlineDialog(); } }; this.onfullscreenchange = (event) => { const isFullScreen = document.fullscreenElement; this.classList.toggle('-fullscreen', isFullScreen !== null); }; } connectedCallback() { if(this.#Connected === false) { //Check Tags const allowTags = this.getAttribute('data-allow-tags') || allTags.map(t => t.tag).join(','); //Bind inner textarea to wc-wysiwyg this.EditorPreviewText = this.querySelector('textarea') as HTMLTextAreaElement; this.EditorPreviewText.className = 'wc-wysiwyg_pr -display-none'; this.EditorPreviewText.oninput = event => { const target = event.target as HTMLTextAreaElement; this.EditorNode.innerHTML = target.value; this.value = target.value; }; this.EditorAllowTags = allowTags.split(','); this.EditorTags = allTags.filter(tag => allowTags.includes(tag.tag)); this.#EditProps = this.getAttribute('data-edit-props') !== null ? JSON.parse(this.getAttribute('data-edit-props')) : false; this.#Autocomplete = this.getAttribute('data-autocomplete') === '1'; this.#HideBottomActions = this.getAttribute('data-hide-bottom-actions') === '1'; //allow inline without ['video','audio','img'] this.EditorInlineActions = this.EditorTags.filter(action => ['video','audio','img'].includes(action.tag) === false); //Check local storage key this.#SotrageKey = this.getAttribute('data-storage'); if(this.#SotrageKey) { let storeValue = window.localStorage.getItem(this.#SotrageKey); if(storeValue) { this.value = storeValue; } } this.EditorActionsSection = el('section', { classList: ['wc-wysiwyg_ec'] }); //Clear format button this.EditorClearFormatBtn = el('button', { classList: ['wc-wysiwyg_btn', '-clear'], attrs: { 'data-hint': this.#t('clearFormat'), }, props: { innerHTML:'Ⱦ', }, }); //Inline selection actions panel this.EditorInlineActionsForm = el('form'); this.EditorInlineDialog = el('dialog', { classList: ['wc-wysiwyg_di'], append: [this.EditorInlineActionsForm, this.EditorClearFormatBtn], props: { //prevent submit onsubmit: event => { event.preventDefault(); event.stopPropagation(); }, } }); //Edit props if(this.#EditProps) { //Inline property editor this.EditorPropertyForm = el('form',{ styles: { display: 'none' }, classList: ['wc-wysiwyg_pf'], props: { onsubmit: event => { event.preventDefault(); this.hideEditorInlineDialog(); }, onpointerup: event => event.stopPropagation(), } }); this.EditorInlineDialog.append(this.EditorPropertyForm); } //Autocomplete form if(this.#Autocomplete) { this.EditorAutoCompleteForm = el('form', { classList: ['wc-wysiwyg_au'], props: { onsubmit: submitEvent => { submitEvent.preventDefault(); submitEvent.stopPropagation(); const tagName = submitEvent.submitter.value; const newEl = el(tagName, { props: { innerHTML: tagName } }); submitEvent.target.parentElement.replaceWith(newEl); newEl.focus(); } } }); } //Actions in footer this.EditorBottomForm = el('fieldset', { classList: ['wc-wysiwyg_bt'], }); if(this.#HideBottomActions === false) { //Toggler btn text/html this.EditorBottomFormViewToggle = el('button', { classList: ['wc-wysiwyg_btn'], attrs: { 'data-hint': this.#t('toggleViewMode'), 'data-mode': 'html5', }, props: { type:'button', innerText: 'текст/html5', onpointerup: event => { let mode = this.EditorBottomFormViewToggle.getAttribute('data-mode'); let newMode = mode === 'html5' ? 'text' : 'html5'; this.EditorBottomFormViewToggle.setAttribute('data-mode', newMode); this.EditorNode.style.display = newMode === 'html5' ? '' : 'none'; this.EditorPreviewText.classList.toggle('-display-none', newMode === 'html5' ? true : false) if(newMode === 'text') { this.EditorPreviewText.value = this.EditorNode.innerHTML; } } } }); ///New
append btn
this.EditorBottomFormNewP = el('button', {
classList: ['wc-wysiwyg_btn'],
attrs: {
'data-hint': this.#t('addNewParahraph'),
},
props: {
type:'button',
innerText: '+ P',
onpointerup: event => {
const P = el('p', {props: {innerText: '/'}});
this.EditorNode.appendChild(P);
P.focus();
}
}
});
//Fullscreen button
this.EditorFullScreenButton = el('button', {
classList: ['wc-wysiwyg_btn'],
attrs: {
'data-hint': this.#t('fullScreen'),
},
props: {
type: "button",
ariaRoleDescription: "button",
innerText: '🖥️',
onpointerup: event => {
this.requestFullscreen();
}
}
});
this.EditorBottomForm.append(
this.EditorBottomFormNewP,
this.EditorBottomFormViewToggle,
this.EditorFullScreenButton,
);
}
//Check custom tags
this.EditorCustomTags = JSON.parse( String(this.getAttribute('data-custom-tags')) );
if(this.EditorCustomTags !== null) {
//Custom panel tags
this.EditorCustomTagsForm = el('fieldset', {
classList: ['wc-wysiwyg_ce'],
});
//Make custom actions buttons panel
this.#makeActionButtons(this.EditorCustomTagsForm, this.EditorCustomTags);
this.appendChild(this.EditorCustomTagsForm);
}
//Node editable
this.EditorNode = el('article', {
classList: ['wc-wysiwyg_content', this.getAttribute('data-content-class')],
props: {
contentEditable: true,
onpointerup: event => {
this.checkCanClearElement(event);
if(this.#EditProps) {
this.checkEditProps(event);
}
},
oninput: event => {
this.updateContent();
if(this.#Autocomplete) {
this.checkAutoComplete();
}
},
//Handle key bindings
onkeydown: event => {
//check hold alt
if(event.altKey) {
//alt+space - move caret to parent node next sibling
if(event.code === 'Space') {
const Selection = window.getSelection();
if(Selection.type === 'Caret') {
//insertAdjacentElement dont support textNodes, first insert span
const span = el('span');
Selection.anchorNode.parentElement.insertAdjacentElement('afterend', span)
//after replace span with textnode and select it
const textN = document.createTextNode(' ');
span.replaceWith(textN);
const range = document.createRange();
range.selectNodeContents(textN);
Selection.removeAllRanges();
Selection.addRange(range);
}
}
}
//tag - hide editor dialog
if(event.code === 'Escape') {
this.hideEditorInlineDialog();
}
//enter - set p as default tag in newline
if(event.code === 'Enter' && event.shiftKey === false) {
const Selection = window.getSelection();
let tagName = 'p';
//tags with return default browser behavior
if(['LI', 'ARTICLE', 'P'].includes(Selection.anchorNode.parentElement.tagName)) {
return true;
}
const p = el(tagName, { props: { innerHTML: ` ` } });
Selection.anchorNode.parentElement.insertAdjacentElement('afterend', p);
const range = document.createRange();
range.selectNodeContents(p);
Selection.removeAllRanges();
Selection.addRange(range);
event.stopPropagation();
event.preventDefault();
}
}
},
});
//Make action buttons
this.#makeActionButtons(this.EditorActionsSection, this.EditorTags);
this.#makeActionButtons(this.EditorInlineActionsForm, this.EditorInlineActions);
//Inser wc-editor after textarea node
this.append(
this.EditorActionsSection,
this.EditorInlineDialog,
this.EditorNode,
this.EditorPreviewText,
this.EditorBottomForm,
);
this.EditorNode.innerHTML = this.EditorPreviewText.value;
this.updateContent();
this.#Connected = true;
}
}
/**
* Update content value and update behaviors
*/
updateContent() {
this.value = this.EditorNode.innerHTML;
this.checkValidity();
if(this.#SotrageKey) {
window.localStorage.setItem(this.#SotrageKey, this.value);
}
this.dispatchEvent(new Event('oninput', { bubbles: true, cancelable: false }));
this.updatePreviewEl(this.getAttribute('data-preview-el'));
}
/**
* Update content at preview element if exists
* @param selector css
*/
updatePreviewEl(selector) {
if(selector) {
const previewEl = window.document.body.querySelector(selector);
if(previewEl) {
previewEl.innerHTML = this.value;
}
}
}
/**
* Validate content
* @returns boolean hasErrors
*/
checkValidity() {
let hasErros = false,
errors= [];
//Check attrs
if(this.getAttribute('required') !== null) {
if(String(this.EditorNode.textContent).length === 0) {
hasErros = true;
errors.push(this.#t('required'));
}
}
if(Number(this.getAttribute('minlength'))) {
if(String(this.EditorNode.textContent).length < Number(this.getAttribute('minlength'))) {
hasErros = true;
errors.push(`${this.#t('minlength')} ${this.getAttribute('minlength')}`);
}
}
if(Number(this.getAttribute('maxlength'))) {
if(String(this.EditorNode.textContent).length > Number(this.getAttribute('maxlength'))) {
hasErros = true;
errors.push(`${this.#t('maxlength')} ${this.getAttribute('maxlength')}`);
}
}
if(this.getAttribute('filtertags')) {
const disallowTags = this.getAttribute('filtertags').split(',');
for (let i = 0; i < disallowTags.length; i++) {
const checkTag = disallowTags[i];
if(this.EditorNode.querySelector(checkTag)) {
hasErros = true;
errors.push(`${this.#t('filtertags')} ${checkTag}`);
break;
}
}
}
this.EditorNode.classList.toggle('-invalid', hasErros);
let oldErrors = this.querySelector('.-errors');
if(oldErrors) {
oldErrors.parentElement.removeChild(oldErrors);
}
if(hasErros) {
const errosEl = el('p', {
props: {
innerHTML: errors.join('
')
},
classList: ['-errors'],
})
this.append(errosEl);
}
return hasErros === false;
}
/**
* Check if need append autocompleted tags variants
*/
checkAutoComplete() {
//CHeck autococmplete
const Selecton = window.getSelection();
if(Selecton !== null && Selecton.anchorNode !== null) {
const SelectionParentEl = Selecton.anchorNode.parentElement as HTMLParagraphElement;
if(SelectionParentEl !== null &&
//if empty selection
Selecton.toString() === '' &&
//and parent node is
SelectionParentEl.nodeName === 'P' && //and parent
is parentElement in EditorNode SelectionParentEl.parentElement === this.EditorNode) { //and parent
inner text starts with `/`
if(SelectionParentEl.innerText.startsWith('/')) {
const parsedTagName = SelectionParentEl.innerText.replace('/', '');
const filteredActions = this.EditorTags.filter(action => action.tag.toLocaleLowerCase().startsWith(parsedTagName.toLocaleLowerCase()));
if(filteredActions.length > 0) {
this.EditorAutoCompleteForm.innerHTML = '';
filteredActions.forEach(action => {
this.EditorAutoCompleteForm.appendChild(el('button', {
classList: ['wc-wysiwyg_btn', `-${action.tag}`],
attrs: {
'data-hint': this.#t(action.tag) || null,
},
props: {
type: 'submit',
innerText: action.tag,
value: action.tag,
}
}))
});
SelectionParentEl.appendChild(this.EditorAutoCompleteForm);
} else {
//clear form
this.EditorAutoCompleteForm.innerHTML = '';
//if exist in DOM detach
if(this.EditorAutoCompleteForm.parentElement) {
this.EditorAutoCompleteForm.parentElement.removeChild(this.EditorAutoCompleteForm);
}
}
}
}
}
}
/**
* Show and position inline actions dialog at targetNode
**/
showEditorInlineDialog() {
this.EditorInlineDialog.show();
}
/**
* Hide inline dialog
**/
hideEditorInlineDialog() {
if(this.#EditProps){
this.EditorPropertyForm.style.display = 'none';
}
this.EditorInlineDialog.close();
}
/**
* Checking and clear tag, if can do it
* @param event
*/
checkCanClearElement(event:Event) {
const eventTarget = event.target as HTMLElement;
if(eventTarget !== this.EditorNode) {
if(eventTarget.nodeName !== 'P'
&& eventTarget.nodeName !== 'SPAN') {
this.EditorClearFormatBtn.style.display = 'inline-block';
this.EditorClearFormatBtn.innerHTML = `Ⱦ ${eventTarget.nodeName}`,
this.EditorClearFormatBtn.onpointerup = (event) => {
eventTarget.replaceWith(document.createTextNode(eventTarget.textContent));
}
this.showEditorInlineDialog();
} else {
this.EditorClearFormatBtn.style.display = 'none';
this.EditorClearFormatBtn.onpointerup = undefined;
}
}
}
/**
* Checking click tag for editable props
**/
checkEditProps(event) {
//Check need edit props
const eventTarget = event.target as HTMLElement;
//Check exist prop\attr
if(this.#EditProps[eventTarget.nodeName]) {
const props = this.#EditProps[eventTarget.nodeName];
event.stopPropagation();
this.EditorPropertyForm.style.display = '';
this.showEditorInlineDialog();
this.EditorPropertyForm.setAttribute('data-tag', eventTarget.nodeName);
this.EditorPropertyForm.innerHTML = '';
for (let i = 0; i < props.length; i++) {
const tagProp = props[i];
const isAttr = tagProp.indexOf('data-') > -1 || tagProp === 'class';
this.EditorPropertyForm.append(el('label', {
props: { innerText: `${tagProp}=` },
append: [
el('input', {
attrs: { placeholder: tagProp },
classList: ['wc-wysiwyg_inp'],
props: {
value: isAttr ? eventTarget.getAttribute(tagProp) : eventTarget[tagProp] || '',
oninput: (eventInput) => {
const eventInputTarget = eventInput.target as HTMLInputElement;
if(tagProp === 'class') {
eventTarget.className = eventInputTarget.value;
}
if((isAttr || tagProp === 'datetime') && eventInputTarget !== null) {
eventTarget.setAttribute(tagProp, eventInputTarget.value)
} else {
eventTarget[tagProp] = eventInputTarget.value;
}
this.updateContent();
}
}
})
]
}));
}
//add submit button for better UX
this.EditorPropertyForm.append(el('button', {
classList: ['wc-wysiwyg_btn'],
props: {
type: 'submit',
innerHTML: '↳',
},
}));
}
}
#makeActionButtons(toEl:HTMLElement, actions) {
for (let i = 0; i < actions.length; i++) {
const action = actions[i];
const button = el('button', {
classList: ['wc-wysiwyg_btn', `-${action.tag}`],
props: {
tabIndex: -1,
type:'button',
textContent: action.is ? `${action.tag} is=${action.is}` : action.tag,
onpointerup: (event) => this.#tag(action.tag, event, action.is),
},
attrs: {
'data-hint': action.hint ? action.hint : this.#t(action.tag) || '-',
}
});
//-wc
//Make button with
toEl.appendChild(button);
}
}
/**
* Default behaviors fot tag actions
*/
#tag = (tag:string, event:Event|false = false, is:boolean|string = false) => {
switch (tag) {
case 'audio':
this.#Media('audio');
break;
case 'video':
this.#Media('video');
break;
case 'img':
this.#Image();
break;
default:
this.#wrapTag(tag, is);
break;
}
}
/**
* Wrap content in