diff --git a/src/js/core/dom.js b/src/js/core/dom.js index d763b1c33..d3db017de 100644 --- a/src/js/core/dom.js +++ b/src/js/core/dom.js @@ -495,10 +495,15 @@ const isEmpty = (node) => { if (len === 0) { return true; - } else if (!isText(node) && len === 1 && node.innerHTML === blankHTML) { + } + else if (!isText(node) && len === 1 && node.innerHTML.trim() === blankHTML) { // ex)


,
return true; - } else if (lists.all(node.childNodes, isText) && node.innerHTML === '') { + } + else if (isText(node) && node.textContent.trim() === '') { + return true; + } + else if (lists.all(node.childNodes, isText) && node.innerHTML === '') { // ex)

, return true; } @@ -1043,14 +1048,18 @@ const insertAfter = (marker, element) => { */ const appendChildNodes = (node, children, skipPaddingBlankHtml) => { lists.each(children, (child) => { - // special case: appending a pure UL/OL to a LI element creates inaccessible LI element - // e.g. press enter in last LI which has UL/OL-subelements - // Therefore, if current node is LI element with no child nodes (text-node) and appending a list, add a br before - if (!skipPaddingBlankHtml && isLi(node) && node.firstChild === null && isList(child)) { - node.appendChild(create("br")); - } - node.appendChild(child); + if (isElement(node)) { + // special case: appending a pure UL/OL to a LI element creates inaccessible LI element + // e.g. press enter in last LI which has UL/OL-subelements + // Therefore, if current node is LI element with no child nodes (text-node) and appending a list, add a br before + if (!skipPaddingBlankHtml && isLi(node) && node.firstChild === null && isList(child)) { + node.appendChild(create("br")); + } + + node.appendChild(child); + } }); + return node; } diff --git a/src/js/module/Codeview.js b/src/js/module/Codeview.js index 7afc48faa..1d3678789 100644 --- a/src/js/module/Codeview.js +++ b/src/js/module/Codeview.js @@ -1,5 +1,4 @@ import dom from '../core/dom'; -import func from '../core/func'; import key from '../core/key'; import range from '../core/range'; import Str from '../core/Str'; diff --git a/src/js/module/CssClass.js b/src/js/module/CssClass.js new file mode 100644 index 000000000..e4145d476 --- /dev/null +++ b/src/js/module/CssClass.js @@ -0,0 +1,340 @@ +import $ from 'jquery'; +import _ from 'underscore'; +import dom from '../core/dom'; + +const isInlineElement = (el) => dom.isInline(el); + +export default class CssClass { + constructor(context) { + this.context = context; + + this.ui = $.summernote.ui; + this.$body = $(document.body); + this.$editor = context.layoutInfo.editor; + this.options = context.options; + this.lang = this.options.langInfo; + this.buttons = context.modules.buttons; + this.editor = context.modules.editor; + this.selection = this.editor.selection; + + this.events = { + // This will be called after modules are initialized. + 'summernote.init': function (we, e) { + //console.log('summernote initialized', we, e); + }, + // This will be called when user releases a key on editable. + 'summernote.keyup': function (we, e) { + // console.log('summernote keyup', we, e); + } + }; + } + + initialize() { + this.addFormats(); + this.initializeButtons(); + + $('.note-toolbar', this.$editor).on('click', '.btn-group-cssclass .dropdown-item', (e) => { + // Prevent dropdown close + e.preventDefault(); + e.stopPropagation(); + + this.refreshDropdown($(e.currentTarget).parent()); + }); + + $('.note-toolbar', this.$editor).on('mousedown', '.btn-group-cssclass > .btn', (e) => { + this.refreshDropdown($(e.currentTarget).next()); + }); + } + + addFormats() { + if (typeof this.options.cssclass === 'undefined') { + this.options.cssclass = {}; + } + + if (typeof this.options.cssclass.classes === 'undefined') { + const rgAlert = /^alert(-.+)?$/; + const rgBtn = /^btn(-.+)?$/; + const rgBg = /^bg-.+$/; + const rgTextColor = /^text-(muted|primary|success|danger|warning|info|dark|white)$/; + const rgTextAlign = /^text-(start|center|end)$/; + const rgDisplay = /^display-[1-4]$/; + const rgWidth = /^w-(25|50|75|100)$/; + const rgRounded = /^rounded(-.+)?$/; + + this.options.cssclass.classes = { + "alert alert-primary": { toggle: rgAlert }, + "alert alert-secondary": { toggle: rgAlert }, + "alert alert-success": { toggle: rgAlert }, + "alert alert-danger": { toggle: rgAlert }, + "alert alert-warning": { toggle: rgAlert }, + "alert alert-info": { toggle: rgAlert }, + "alert alert-light": { toggle: rgAlert }, + "alert alert-dark": { toggle: rgAlert }, + "bg-primary": { displayClass: "px-2 py-1 text-white", inline: true, toggle: rgBg }, + "bg-secondary": { displayClass: "px-2 py-1", inline: true, toggle: rgBg }, + "bg-success": { displayClass: "px-2 py-1 text-white", inline: true, toggle: rgBg }, + "bg-danger": { displayClass: "px-2 py-1 text-white", inline: true, toggle: rgBg }, + "bg-warning": { displayClass: "px-2 py-1 text-white", inline: true, toggle: rgBg }, + "bg-info": { displayClass: "px-2 py-1 text-white", inline: true, toggle: rgBg }, + "bg-light": { displayClass: "px-2 py-1", inline: true, toggle: rgBg }, + "bg-dark": { displayClass: "px-2 py-1 text-white", inline: true, toggle: rgBg }, + "bg-white": { displayClass: "px-2 py-1 border", inline: true, toggle: rgBg }, + "rtl": { displayClass: "text-uppercase", inline: true, toggle: /^ltr$/ }, + "ltr": { displayClass: "text-uppercase", inline: true, toggle: /^rtl$/ }, + "text-muted": { inline: true, toggle: rgTextColor }, + "text-primary": {inline: true, toggle: rgTextColor }, + "text-success": {inline: true, toggle: rgTextColor }, + "text-danger": { inline: true, toggle: rgTextColor }, + "text-warning": { inline: true, toggle: rgTextColor }, + "text-info": { inline: true, toggle: rgTextColor }, + "text-dark": { inline: true, toggle: rgTextColor }, + "text-white": { displayClass: "bg-gray", inline: true, toggle: rgTextColor }, + "font-weight-medium": { inline: true }, + "w-25": { displayClass: "px-2 py-1 bg-light border", toggle: rgWidth }, + "w-50": { displayClass: "px-2 py-1 bg-light border", toggle: rgWidth }, + "w-75": { displayClass: "px-2 py-1 bg-light border", toggle: rgWidth }, + "w-100": { displayClass: "px-2 py-1 bg-light border", toggle: rgWidth }, + "btn btn-primary": { inline: true, toggle: rgBtn, predicate: "a" }, + "btn btn-secondary": { inline: true, toggle: rgBtn, predicate: "a" }, + "btn btn-success": { inline: true, toggle: rgBtn, predicate: "a" }, + "btn btn-danger": { inline: true, toggle: rgBtn, predicate: "a" }, + "btn btn-warning": { inline: true, toggle: rgBtn, predicate: "a" }, + "btn btn-info": { inline: true, toggle: rgBtn, predicate: "a" }, + "btn btn-light": { inline: true, toggle: rgBtn, predicate: "a" }, + "btn btn-dark": { inline: true, toggle: rgBtn, predicate: "a" }, + "rounded-0": { displayClass: "px-2 py-1 bg-light border", toggle: rgRounded }, + "rounded-1": { displayClass: "px-2 py-1 bg-light border rounded-1", toggle: rgRounded }, + "rounded-2": { displayClass: "px-2 py-1 bg-light border rounded-2", toggle: rgRounded }, + "rounded-3": { displayClass: "px-2 py-1 bg-light border rounded-3", toggle: rgRounded }, + "rounded-4": { displayClass: "px-2 py-2 bg-light border rounded-4", toggle: rgRounded }, + "rounded-5": { displayClass: "px-2 py-2 bg-light border rounded-5", toggle: rgRounded }, + "rounded-pill": { displayClass: "px-2 py-1 bg-light border rounded-pill", toggle: rgRounded }, + "list-unstyled": { }, + "display-1": { displayClass: "fs-h1", toggle: rgDisplay }, + "display-2": { displayClass: "fs-h2", toggle: rgDisplay }, + "display-3": { displayClass: "fs-h3", toggle: rgDisplay }, + "display-4": { displayClass: "fs-h4", toggle: rgDisplay }, + "lead": { } + }; + } + + if (typeof this.options.cssclass.imageShapes === 'undefined') { + this.options.cssclass.imageShapes = { + "img-fluid": { inline: true }, + "border": { inline: true }, + "rounded": { toggle: /^(rounded(-.+)?)|img-thumbnail$/, inline: true }, + "rounded-circle": { toggle: /^(rounded(-.+)?)|img-thumbnail$/, inline: true }, + "img-thumbnail": { toggle: /^rounded(-.+)?$/, inline: true }, + "shadow-sm": { toggle: /^(shadow(-.+)?)$/, inline: true }, + "shadow": { toggle: /^(shadow(-.+)?)$/, inline: true }, + "shadow-lg": { toggle: /^(shadow(-.+)?)$/, inline: true } + }; + } + } + + initializeButtons() { + this.context.memo('button.cssclass', () => { + return this.ui.buttonGroup({ + className: 'btn-group-cssclass', + children: [ + this.ui.button({ + className: 'dropdown-toggle', + contents: this.ui.icon("fab fa-css3"), // TODO + callback: (btn) => { + btn.data("placement", "bottom") + .data("trigger", 'hover') + .attr("title", this.lang.attrs.cssClass) + .tooltip(); + }, + data: { + toggle: 'dropdown' + } + }), + this.ui.dropdown({ + className: 'dropdown-cssclass scrollable-menu', + items: _.keys(this.options.cssclass.classes), + template: (item) => { + const obj = this.options.cssclass.classes[item] || {}; + + let cssClass = item; + if (obj.displayClass) { + cssClass += " " + obj.displayClass; + } + if (!obj.inline) { + cssClass += " d-block"; + } + + const cssStyle = obj.style ? ' style="{0}"'.format(obj.style) : ''; + return `${item}`; + }, + click: (e, namespace, value) => { + e.preventDefault(); + + var ddi = $(e.target).closest('[data-value]'); + value = value || ddi.data('value'); + var obj = this.options.cssclass.classes[value] || {}; + + this.applyClassToSelection(value, obj); + } + }) + ] + }).render(); + }); + + // Image shape stuff + this.context.memo('button.imageShapes', () => { + const imageShapes = Object.keys(this.options.cssclass.imageShapes); + const button = this.ui.buttonGroup({ + className: 'btn-group-imageshape', + children: [ + this.ui.button({ + className: 'dropdown-toggle', + contents: this.ui.icon("fab fa-css3"), + callback: (btn) => { + btn.data("placement", "bottom"); + btn.data("trigger", "hover"); + btn.attr("title", this.lang.imageShapes.tooltip); + btn.tooltip(); + + btn.on('click', (e) => { + this.refreshDropdown($(e.currentTarget).next(), $(this.context.layoutInfo.editable.data('target')), true); + }); + }, + data: { + toggle: 'dropdown' + } + }), + this.ui.dropdownCheck({ + className: 'dropdown-shape', + checkClassName: this.options.icons.menuCheck, + items: imageShapes, + template: (item) => { + const index = imageShapes.indexOf(item); + return this.lang.imageShapes.tooltipShapeOptions[index]; + }, + click: (e) => { + e.preventDefault(); + + const ddi = $(e.target).closest('[data-value]'); + const value = ddi.data('value'); + const obj = this.options.cssclass.imageShapes[value] || {}; + + this.applyClassToSelection(value, obj); + } + }) + ] + }); + + return button.render(); + }); + } + + applyClassToSelection(value, obj) { + const controlNode = $(this.editor.restoreTarget()); + const sel = this.selection.nativeSelection; + let node = $(sel.focusNode.parentElement, ".note-editable"); + const caret = sel.type === 'None' || sel.type === 'Caret'; + + const apply = (el) => { + if (el.is('.' + value.replace(' ', '.'))) { + // "btn btn-info" > ".btn.btn-info" + // Just remove the same style + el.removeClass(value); + if (!el.attr('class')) { + el.removeAttr('class'); + } + + if (isInlineElement(el[0]) && !el[0].attributes.length) { + // Unwrap the node when it is inline and no attributes are present + el.replaceWith(el.html()); + } + } + else { + if (obj.toggle) { + // Remove equivalent classes first + const classNames = (el.attr('class') || '').split(' '); + _.each(classNames, (name) => { + if (name && name !== value && obj.toggle.test(name)) { + el.removeClass(name); + } + }); + } + + el.toggleClass(value); + } + } + + this.editor.beforeCommand(); + + if (controlNode.length) { + // Most likely IMG is selected + if (obj.inline) { + apply(controlNode); + } + } + else { + if (!obj.inline) { + // Apply a block-style only to a block-level element + if (isInlineElement(node[0])) { + // Traverse parents until a block-level element is found + node = $(dom.closest(node, n => !isInlineElement(n))); + // while (node.length && isInlineElement(node[0])) { + // node = node.parent(); + // } + } + + if (node.length && !dom.isEditableRoot(node[0])) { + apply(node); + } + } + else if (obj.inline && caret) { + apply(node); + } + else if (sel.rangeCount) { + const range = sel.getRangeAt(0).cloneRange(); + const span = $(''); + range.surroundContents(span[0]); + sel.removeAllRanges(); + sel.addRange(range); + } + } + + this.editor.afterCommand(); + } + + refreshDropdown(drop, node /* selectedNode */, noBubble) { + node = node || $(this.selection.nativeSelection.focusNode, ".note-editable"); + + drop.find('> .dropdown-item').each(function () { + let ddi = $(this), + curNode = node, + value = ddi.data('value'), + //obj = options.cssclass.classes[value] || {}, + expr = '.' + value.replace(' ', '.'), + match = false; + + while (curNode.length) { + if (curNode.is(expr)) { + match = true; + break; + } + + if (noBubble) { + break; + } + + if (dom.isEditableRoot(curNode)) { + break; + } + + curNode = curNode.parent(); + } + + ddi.toggleClass('checked', match); + }); + } + + destroy() { + // ??? + } +} \ No newline at end of file diff --git a/src/js/module/HelpDialog.js b/src/js/module/HelpDialog.js index 6c0b4e832..df92c3f4a 100644 --- a/src/js/module/HelpDialog.js +++ b/src/js/module/HelpDialog.js @@ -15,7 +15,7 @@ export default class HelpDialog { initialize() { const $container = this.options.dialogsInBody ? this.$body : this.options.container; const body = [ - '

', + '

', 'Summernote @@VERSION@@ · ', 'Project · ', 'Issues', @@ -29,8 +29,8 @@ export default class HelpDialog { footer: body, callback: ($node) => { $node.find('.modal-body,.note-modal-body').css({ - 'max-height': 300, - 'overflow': 'scroll', + 'max-height': 350, + 'overflow-y': 'scroll', }); }, }).render().appendTo($container); @@ -43,15 +43,18 @@ export default class HelpDialog { createShortcutList() { const keyMap = this.options.keyMap[env.isMac ? 'mac' : 'pc']; - return Object.keys(keyMap).map((key) => { + + const $root = $('

'); + const $list = $root.find('.help-list'); + + Object.keys(keyMap).forEach(key => { const command = keyMap[key]; - const $row = $('
'); - $row.append($('').css({ - 'width': 180, - 'margin-right': 10, - })).append($('').html(this.context.memo('help.' + command) || command)); - return $row.html(); - }).join(''); + $list + .append($('
' + key + '
')) + .append($('
').html(this.context.memo('help.' + command) || command)); + }); + + return $root.html(); } /** diff --git a/src/js/settings.js b/src/js/settings.js index 72f3feccd..12fb687f1 100644 --- a/src/js/settings.js +++ b/src/js/settings.js @@ -29,6 +29,7 @@ import VideoDialog from './module/VideoDialog'; import HelpDialog from './module/HelpDialog'; //import AirPopover from './module/AirPopover'; import HintPopover from './module/HintPopover'; +import CssClass from './module/CssClass'; $.summernote = $.extend($.summernote, { version: '@@VERSION@@', @@ -56,6 +57,7 @@ $.summernote = $.extend($.summernote, { 'autoSync': AutoSync, 'autoReplace': AutoReplace, 'placeholder': Placeholder, + 'cssclass': CssClass, 'buttons': Buttons, 'toolbar': Toolbar, 'linkDialog': LinkDialog, diff --git a/src/styles/sm/js/globalinit.js b/src/styles/sm/js/globalinit.js index 8509b68dc..40a97231f 100644 --- a/src/styles/sm/js/globalinit.js +++ b/src/styles/sm/js/globalinit.js @@ -75,7 +75,7 @@ let summernote_image_upload_url; ['edit', ['undo', 'redo']], ['text', ['bold', 'italic', 'underline', 'moreFontStyles']], //['color', ['forecolor', 'backcolor']], - ['font', ['fontname', 'color', 'fontsize']], + //['font', ['fontname', 'color', 'fontsize']], ['para', ['style', 'cssclass', 'ul', 'ol', 'paragraph', 'clear']], ['insert', ['link', 'image', 'video', 'table', 'hr']], ['view', ['codeview', 'fullscreen', 'help']]